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
39 changes: 39 additions & 0 deletions apps/api-gateway/src/ecosystem/ecosystem.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { CreateIntentTemplateDto, UpdateIntentTemplateDto } from '../utilities/d
import { GetAllIntentTemplatesDto } from '../utilities/dtos/get-all-intent-templates.dto';
import { IIntentTemplateList } from '@credebl/common/interfaces/intents-template.interface';
import { IPaginationSortingDto, PaginatedResponse } from 'libs/common/src/interfaces/interface';
import { CreateIntentNoticeDto, UpdateIntentNoticeDto } from '../oid4vc-verification/dtos/create-intent-notice.dto';

@Injectable()
export class EcosystemService {
Expand Down Expand Up @@ -251,4 +252,42 @@ export class EcosystemService {
async getCreateEcosystemInvitationStatus(email: string, status: Invitation): Promise<boolean> {
return this.natsClient.sendNatsMessage(this.serviceProxy, 'get-ecosystem-created-status', { email, status });
}

async createIntentNotice(createIntentNoticeDto: CreateIntentNoticeDto, userDetails: user): Promise<object> {
const payload = { createIntentNoticeDto, userId: userDetails.id };
return this.natsClient.sendNatsMessage(this.serviceProxy, 'create-intent-notice', payload);
}

async getIntentNotices(id?: string, intentId?: string): Promise<object[]> {
return this.natsClient.sendNatsMessage(this.serviceProxy, 'get-intent-notices', { id, intentId });
}

async getIntentNoticesByEcosystemId(
ecosystemId: string,
pageNumber: number,
pageSize: number,
search: string,
intentId?: string
): Promise<object> {
return this.natsClient.sendNatsMessage(this.serviceProxy, 'get-intent-notices-by-ecosystem', {
ecosystemId,
pageNumber,
pageSize,
search,
intentId
});
}

async updateIntentNotice(
id: string,
updateIntentNoticeDto: UpdateIntentNoticeDto,
userDetails: user
): Promise<object> {
const payload = { id, updateIntentNoticeDto, userId: userDetails.id };
return this.natsClient.sendNatsMessage(this.serviceProxy, 'update-intent-notice', payload);
}

async deleteIntentNotice(id: string, userDetails: user): Promise<object> {
return this.natsClient.sendNatsMessage(this.serviceProxy, 'delete-intent-notice', { id, userId: userDetails.id });
}
}
180 changes: 180 additions & 0 deletions apps/api-gateway/src/ecosystem/intent/intent.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { PaginationDto } from '@credebl/common/dtos/pagination.dto';
import { TrimStringParamPipe } from '@credebl/common/cast.helper';
import { EcosystemService } from '../ecosystem.service';
import { ForbiddenErrorDto } from '../../dtos/forbidden-error.dto';
import { CreateIntentNoticeDto, UpdateIntentNoticeDto } from '../../oid4vc-verification/dtos/create-intent-notice.dto';

@UseFilters(CustomExceptionFilter)
@Controller('intent')
Expand Down Expand Up @@ -611,4 +612,183 @@ export class IntentController {
};
return res.status(HttpStatus.OK).json(finalResponse);
}

@Post('/notice')
@ApiBearerAuth()
@Roles(OrgRoles.ECOSYSTEM_LEAD)
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
@ApiOperation({ summary: 'Create intent notice', description: 'Stores a notice URL associated with an intent.' })
@ApiResponse({ status: HttpStatus.CREATED, description: 'Intent notice created successfully', type: ApiResponseDto })
async createIntentNotice(
@Body() createIntentNoticeDto: CreateIntentNoticeDto,
@User() user: PrismaUser,
@Res() res: Response
): Promise<Response> {
const result = await this.ecosystemService.createIntentNotice(createIntentNoticeDto, user);
const finalResponse: IResponse = {
statusCode: HttpStatus.CREATED,
message: ResponseMessages.intentNotice.success.create,
data: result
};
return res.status(HttpStatus.CREATED).json(finalResponse);
}

@Get('/notice')
@ApiBearerAuth()
@Roles(OrgRoles.ECOSYSTEM_LEAD, OrgRoles.ECOSYSTEM_MEMBER)
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
@ApiOperation({
summary: 'Get intent notices',
description: 'Retrieves intent notices. Filter by notice id or intentId (both optional).'
})
@ApiQuery({ name: 'id', required: false, type: String, description: 'Filter by notice PK UUID (optional)' })
@ApiQuery({ name: 'intentId', required: false, type: String, description: 'Filter by intent UUID (optional)' })
@ApiResponse({ status: HttpStatus.OK, description: 'Intent notices fetched successfully', type: ApiResponseDto })
async getIntentNotices(
@Res() res: Response,
@Query(
'id',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid notice ID');
}
})
)
id?: string,
@Query(
'intentId',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid intent ID');
}
})
)
intentId?: string
): Promise<Response> {
const result = await this.ecosystemService.getIntentNotices(id, intentId);
const finalResponse: IResponse = {
statusCode: HttpStatus.OK,
message: ResponseMessages.intentNotice.success.fetchAll,
data: result
};
return res.status(HttpStatus.OK).json(finalResponse);
}
Comment on lines +636 to +679
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Mar 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

GET /intent/notice allows unscoped cross-ecosystem reads.

At Line 647-Line 672, both filters are optional and no ecosystem/user scope is sent downstream. A user with ecosystem role access can retrieve notices outside their intended scope.

🔧 Suggested direction
 async getIntentNotices(
@@
-  ): Promise<Response> {
+  ): Promise<Response> {
+    if (!id && !intentId) {
+      throw new BadRequestException('Either id or intentId is required');
+    }

Additionally, prefer a scoped contract (ecosystemId + caller context) for this read path, or deprecate this route in favor of GET /intent/ecosystem/:ecosystemId/notice.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Get('/notice')
@ApiBearerAuth()
@Roles(OrgRoles.ECOSYSTEM_LEAD, OrgRoles.ECOSYSTEM_MEMBER)
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
@ApiOperation({
summary: 'Get intent notices',
description: 'Retrieves intent notices. Filter by notice id or intentId (both optional).'
})
@ApiQuery({ name: 'id', required: false, type: String, description: 'Filter by notice PK UUID (optional)' })
@ApiQuery({ name: 'intentId', required: false, type: String, description: 'Filter by intent UUID (optional)' })
@ApiResponse({ status: HttpStatus.OK, description: 'Intent notices fetched successfully', type: ApiResponseDto })
async getIntentNotices(
@Res() res: Response,
@Query(
'id',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid notice ID');
}
})
)
id?: string,
@Query(
'intentId',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid intent ID');
}
})
)
intentId?: string
): Promise<Response> {
const result = await this.ecosystemService.getIntentNotices(id, intentId);
const finalResponse: IResponse = {
statusCode: HttpStatus.OK,
message: ResponseMessages.intentNotice.success.fetchAll,
data: result
};
return res.status(HttpStatus.OK).json(finalResponse);
}
`@Get`('/notice')
`@ApiBearerAuth`()
`@Roles`(OrgRoles.ECOSYSTEM_LEAD, OrgRoles.ECOSYSTEM_MEMBER)
`@UseGuards`(AuthGuard('jwt'), EcosystemRolesGuard)
`@ApiOperation`({
summary: 'Get intent notices',
description: 'Retrieves intent notices. Filter by notice id or intentId (both optional).'
})
`@ApiQuery`({ name: 'id', required: false, type: String, description: 'Filter by notice PK UUID (optional)' })
`@ApiQuery`({ name: 'intentId', required: false, type: String, description: 'Filter by intent UUID (optional)' })
`@ApiResponse`({ status: HttpStatus.OK, description: 'Intent notices fetched successfully', type: ApiResponseDto })
async getIntentNotices(
`@Res`() res: Response,
`@Query`(
'id',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid notice ID');
}
})
)
id?: string,
`@Query`(
'intentId',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid intent ID');
}
})
)
intentId?: string
): Promise<Response> {
if (!id && !intentId) {
throw new BadRequestException('Either id or intentId is required');
}
const result = await this.ecosystemService.getIntentNotices(id, intentId);
const finalResponse: IResponse = {
statusCode: HttpStatus.OK,
message: ResponseMessages.intentNotice.success.fetchAll,
data: result
};
return res.status(HttpStatus.OK).json(finalResponse);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api-gateway/src/ecosystem/intent/intent.controller.ts` around lines 636
- 679, The getIntentNotices controller currently allows unscoped reads because
id and intentId are optional and no ecosystem context is enforced; fix by
requiring and validating caller ecosystem scope and passing it to the service:
extract the caller's ecosystemId from request/user context (or require an
ecosystemId query/path param), validate it (UUID and that the user has access)
and then call ecosystemService.getIntentNotices(id, intentId, ecosystemId) or
reject the request if the caller is out-of-scope; update the EcosystemRolesGuard
or add an explicit check in getIntentNotices (and update the service signature)
so notices are only returned for the scoped ecosystem, or deprecate this route
in favor of a scoped endpoint like GET /intent/ecosystem/:ecosystemId/notice.

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.

The ecosystem filter is applied in DB query

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check getIntentNotices implementation in the ecosystem service and repository
rg -n "getIntentNotices" --type ts -A 20 -B 2

Length of output: 29429


@tipusinghaw, that's correct — when intentId is supplied, the repository does derive the intent's ecosystemId and scopes the DB query accordingly (Lines 1726–1735 in ecosystem.repository.ts). Thanks for the clarification.

That said, two edge cases remain unscoped:

  1. Only id supplied — the where clause becomes { id } with no ecosystem filter, so a caller can fetch any notice by its PK regardless of ecosystem membership.
  2. Neither id nor intentId supplied — the where clause is {}, which returns all intent_notices across every ecosystem.

A minimal guard at the controller level would close both gaps:

async getIntentNotices(
  `@Res`() res: Response,
  id?: string,
  intentId?: string
): Promise<Response> {
  if (!id && !intentId) {
    throw new BadRequestException('Either id or intentId is required');
  }
  const result = await this.ecosystemService.getIntentNotices(id, intentId);
  ...
}

And for the id-only path, passing the caller's verified ecosystemId (from the JWT / EcosystemRolesGuard context) down to the repository so it can add intent: { is: { ecosystemId } } to the where clause would fully close the cross-ecosystem read.

Would you like to address these two residual cases, or is the GET /intent/notice route intentionally an admin/internal endpoint where cross-ecosystem reads are acceptable?


🧠 Learnings used
Learnt from: tipusinghaw
Repo: credebl/platform PR: 1584
File: apps/api-gateway/src/oid4vc-verification/dtos/create-intent-notice.dto.ts:10-13
Timestamp: 2026-03-18T12:02:15.511Z
Learning: In the credebl/platform repository, `noticeUrl` URL validation for intent notices is intentionally handled at the service layer via the `validateNoticeUrl` helper in `apps/ecosystem/src/ecosystem.helper.ts`, which performs a live HTTP reachability check, rather than purely at the DTO layer with `IsUrl()`. Do not flag the absence of `IsUrl()` on `noticeUrl` in `CreateIntentNoticeDto` as a missing validation.

Learnt from: pranalidhanavade
Repo: credebl/platform PR: 1547
File: libs/prisma-service/prisma/migrations/20260119130717_intent_table_creation/migration.sql:4-11
Timestamp: 2026-01-22T12:39:42.299Z
Learning: In the intents table (libs/prisma-service/prisma/schema.prisma), ecosystemId is NOT NULL because intent records are never created without an ecosystemId in the application workflow. This differs from ecosystem_invitations where ecosystemId is nullable to support deferred ecosystem creation.

Learnt from: sujitaw
Repo: credebl/platform PR: 1545
File: libs/prisma-service/prisma/schema.prisma:793-793
Timestamp: 2026-01-19T10:03:19.860Z
Learning: In the ecosystem_invitations model, deletions involving invitedOrg are handled at the application level with conditions matching both ecosystemId and orgId, rather than relying on database-level cascade behavior.

Learnt from: sujitaw
Repo: credebl/platform PR: 1545
File: libs/prisma-service/prisma/schema.prisma:792-792
Timestamp: 2026-01-19T09:58:13.669Z
Learning: In the ecosystem_invitations model (libs/prisma-service/prisma/schema.prisma), ecosystemId is intentionally nullable to support a workflow where users create their ecosystem first and the ecosystemId is added later. Application-level validation prevents duplicate records with the same email when both ecosystemId and orgId are null, addressing the PostgreSQL NULL uniqueness behavior.


@Get('/ecosystem/:ecosystemId/notice')
@ApiBearerAuth()
@Roles(OrgRoles.ECOSYSTEM_LEAD, OrgRoles.ECOSYSTEM_MEMBER)
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
@ApiOperation({
summary: 'Get intent notices by ecosystem',
description: 'Retrieves all intent notices for an ecosystem with pagination, search, and optional intent filter.'
})
@ApiQuery({ name: 'pageNumber', required: false, type: Number, description: 'Page number (default: 1)' })
@ApiQuery({ name: 'pageSize', required: false, type: Number, description: 'Page size (default: 10, max: 100)' })
@ApiQuery({ name: 'search', required: false, type: String, description: 'Search by notice URL' })
@ApiQuery({ name: 'intentId', required: false, type: String, description: 'Filter by intent UUID' })
@ApiResponse({ status: HttpStatus.OK, description: 'Intent notices fetched successfully', type: ApiResponseDto })
async getIntentNoticesByEcosystemId(
@Param(
'ecosystemId',
TrimStringParamPipe,
new ParseUUIDPipe({
exceptionFactory: (): Error => {
throw new BadRequestException(ResponseMessages.ecosystem.error.invalidFormatOfEcosystemId);
}
})
)
ecosystemId: string,
@Query() pageDto: PaginationDto,
@Query(
'intentId',
new ParseUUIDPipe({
version: '4',
optional: true,
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid intent ID');
}
})
)
intentId: string,
@Res() res: Response
): Promise<Response> {
const result = await this.ecosystemService.getIntentNoticesByEcosystemId(
ecosystemId,
pageDto.pageNumber,
pageDto.pageSize,
pageDto.search,
intentId
);
return res.status(HttpStatus.OK).json({
statusCode: HttpStatus.OK,
message: ResponseMessages.intentNotice.success.fetchAll,
data: result
});
}

@Put('/notice/:id')
@ApiBearerAuth()
@Roles(OrgRoles.ECOSYSTEM_LEAD)
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
@ApiOperation({
summary: 'Update intent notice',
description: 'Updates the notice URL for a given notice ID.'
})
@ApiResponse({ status: HttpStatus.OK, description: 'Intent notice updated successfully', type: ApiResponseDto })
async updateIntentNotice(
@Param(
'id',
TrimStringParamPipe,
new ParseUUIDPipe({
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid notice ID');
}
})
)
id: string,
@Body() updateIntentNoticeDto: UpdateIntentNoticeDto,
@User() user: PrismaUser,
@Res() res: Response
): Promise<Response> {
const result = await this.ecosystemService.updateIntentNotice(id, updateIntentNoticeDto, user);
const finalResponse: IResponse = {
statusCode: HttpStatus.OK,
message: ResponseMessages.intentNotice.success.update,
data: result
};
return res.status(HttpStatus.OK).json(finalResponse);
}

@Delete('/notice/:id')
@ApiBearerAuth()
@Roles(OrgRoles.ECOSYSTEM_LEAD)
@UseGuards(AuthGuard('jwt'), EcosystemRolesGuard)
@ApiOperation({ summary: 'Delete intent notice', description: 'Deletes an intent notice by its ID.' })
@ApiResponse({ status: HttpStatus.OK, description: 'Intent notice deleted successfully', type: ApiResponseDto })
async deleteIntentNotice(
@Param(
'id',
TrimStringParamPipe,
new ParseUUIDPipe({
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid notice ID');
}
})
)
id: string,
@User() user: PrismaUser,
@Res() res: Response
): Promise<Response> {
const result = await this.ecosystemService.deleteIntentNotice(id, user);
const finalResponse: IResponse = {
statusCode: HttpStatus.OK,
message: ResponseMessages.intentNotice.success.delete,
data: result
};
return res.status(HttpStatus.OK).json(finalResponse);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDefined, IsOptional, IsString, IsUrl, IsUUID } from 'class-validator';

export class CreateIntentNoticeDto {
@ApiProperty({ description: 'Intent ID to associate the notice with', example: 'uuid-of-intent' })
@IsDefined()
@IsUUID()
intentId: string;

@ApiProperty({ description: 'URL of the notice', example: 'https://example.com/notice' })
@IsDefined()
@IsString()
noticeUrl: string;

@ApiPropertyOptional({ description: 'Organization ID (optional)', example: 'uuid-of-org' })
@IsOptional()
@IsUUID()
orgId?: string;
}

export class UpdateIntentNoticeDto {
@ApiPropertyOptional({ description: 'URL of the notice', example: 'https://example.com/notice' })
@IsOptional()
@IsUrl()
noticeUrl?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ export class Oid4vcVerificationController {
description: 'Verification presentation created successfully.',
type: ApiResponseDto
})
@ApiQuery({ name: 'ecosystemId', required: true })
@ApiBearerAuth()
@Roles(OrgRoles.OWNER)
@UseGuards(AuthGuard('jwt'), OrgRolesGuard)
Expand All @@ -341,19 +342,29 @@ export class Oid4vcVerificationController {
})
)
verifierId: string,
@Query(
'ecosystemId',
new ParseUUIDPipe({
exceptionFactory: (): Error => {
throw new BadRequestException('Invalid ecosystem ID');
}
})
)
ecosystemId: string,
@User() user: user,
@Body() createIntentDto: CreateIntentBasedVerificationDto,
@Res() res: Response
): Promise<Response> {
this.logger.debug(
`[createIntentBasedVerificationPresentation] Called with orgId=${orgId}, verifierId=${verifierId}, intent=${createIntentDto?.intent}, user=${user.id}`
`[createIntentBasedVerificationPresentation] Called with orgId=${orgId}, verifierId=${verifierId}, intent=${createIntentDto?.intent}, ecosystemId=${ecosystemId}, user=${user.id}`
);

const presentation = await this.oid4vcVerificationService.createIntentBasedVerificationPresentation(
orgId,
verifierId,
createIntentDto,
user
user,
ecosystemId
);

this.logger.debug(`[createIntentBasedVerificationPresentation] Presentation created successfully`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,20 @@ export class Oid4vcVerificationService {
orgId: string,
verifierId: string,
createIntentDto: CreateIntentBasedVerificationDto,
userDetails: user
userDetails: user,
ecosystemId: string
): Promise<object> {
const { intent, responseMode, requestSigner, expectedOrigins } = createIntentDto;
const payload = { orgId, verifierId, intent, responseMode, requestSigner, expectedOrigins, userDetails };
const payload = {
orgId,
verifierId,
intent,
responseMode,
requestSigner,
expectedOrigins,
userDetails,
ecosystemId
};
this.logger.debug(
`[createIntentBasedVerificationPresentation] Called with orgId=${orgId}, verifierId=${verifierId}, intent=${intent}, user=${userDetails?.id}`
);
Expand Down
Loading
Loading