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
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';

export class InboxChannelConnectionResponseDto {
@ApiProperty({
description: 'The unique identifier of the channel connection.',
type: String,
})
identifier: string;
}

export class InboxListChannelConnectionsResponseDto {
@ApiProperty({ type: [InboxChannelConnectionResponseDto] })
data: InboxChannelConnectionResponseDto[];

@ApiProperty({ type: String, nullable: true })
next: string | null;

@ApiProperty({ type: String, nullable: true })
previous: string | null;
}
28 changes: 28 additions & 0 deletions apps/api/src/app/inbox/dtos/inbox-channel-endpoint-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { ChannelEndpointType, ENDPOINT_TYPES } from '@novu/shared';

export class InboxChannelEndpointResponseDto {
@ApiProperty({
description: 'The unique identifier of the channel endpoint.',
type: String,
})
identifier: string;

@ApiProperty({
description: 'Type of channel endpoint',
enum: Object.values(ENDPOINT_TYPES),
example: ENDPOINT_TYPES.SLACK_CHANNEL,
})
type: ChannelEndpointType;
}

export class InboxListChannelEndpointsResponseDto {
@ApiProperty({ type: [InboxChannelEndpointResponseDto] })
data: InboxChannelEndpointResponseDto[];

@ApiProperty({ type: String, nullable: true })
next: string | null;

@ApiProperty({ type: String, nullable: true })
previous: string | null;
}
16 changes: 16 additions & 0 deletions apps/api/src/app/inbox/dtos/inbox-dto.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ChannelConnectionEntity, ChannelEndpointEntity } from '@novu/dal';
import { InboxChannelConnectionResponseDto } from './inbox-channel-connection-response.dto';
import { InboxChannelEndpointResponseDto } from './inbox-channel-endpoint-response.dto';

export function mapChannelConnectionToInboxDto(entity: ChannelConnectionEntity): InboxChannelConnectionResponseDto {
return {
identifier: entity.identifier,
};
}

export function mapChannelEndpointToInboxDto(entity: ChannelEndpointEntity): InboxChannelEndpointResponseDto {
return {
identifier: entity.identifier,
type: entity.type,
};
}
120 changes: 16 additions & 104 deletions apps/api/src/app/inbox/inbox.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,26 +26,14 @@ import {
TriggerRequestCategoryEnum,
UserSessionData,
} from '@novu/shared';
import { CreateChannelConnectionRequestDto } from '../channel-connections/dtos/create-channel-connection-request.dto';
import { mapChannelConnectionEntityToDto } from '../channel-connections/dtos/dto.mapper';
import { GetChannelConnectionResponseDto } from '../channel-connections/dtos/get-channel-connection-response.dto';
import { ListChannelConnectionsQueryDto } from '../channel-connections/dtos/list-channel-connections-query.dto';
import { ListChannelConnectionsResponseDto } from '../channel-connections/dtos/list-channel-connections-response.dto';
import { CreateChannelConnectionCommand } from '../channel-connections/usecases/create-channel-connection/create-channel-connection.command';
import { CreateChannelConnection } from '../channel-connections/usecases/create-channel-connection/create-channel-connection.usecase';
import { DeleteChannelConnectionCommand } from '../channel-connections/usecases/delete-channel-connection/delete-channel-connection.command';
import { DeleteChannelConnection } from '../channel-connections/usecases/delete-channel-connection/delete-channel-connection.usecase';
import { GetChannelConnectionCommand } from '../channel-connections/usecases/get-channel-connection/get-channel-connection.command';
import { GetChannelConnection } from '../channel-connections/usecases/get-channel-connection/get-channel-connection.usecase';
import { ListChannelConnectionsCommand } from '../channel-connections/usecases/list-channel-connections/list-channel-connections.command';
import { ListChannelConnections } from '../channel-connections/usecases/list-channel-connections/list-channel-connections.usecase';
import { CreateChannelEndpointRequest } from '../channel-endpoints/dtos/create-channel-endpoint-request.dto';
import { mapChannelEndpointEntityToDto } from '../channel-endpoints/dtos/dto.mapper';
import { GetChannelEndpointResponseDto } from '../channel-endpoints/dtos/get-channel-endpoint-response.dto';
import { ListChannelEndpointsQueryDto } from '../channel-endpoints/dtos/list-channel-endpoints-query.dto';
import { ListChannelEndpointsResponseDto } from '../channel-endpoints/dtos/list-channel-endpoints-response.dto';
import { CreateChannelEndpointCommand } from '../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.command';
import { CreateChannelEndpoint } from '../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase';
import { DeleteChannelEndpointCommand } from '../channel-endpoints/usecases/delete-channel-endpoint/delete-channel-endpoint.command';
import { DeleteChannelEndpoint } from '../channel-endpoints/usecases/delete-channel-endpoint/delete-channel-endpoint.usecase';
import { GetChannelEndpointCommand } from '../channel-endpoints/usecases/get-channel-endpoint/get-channel-endpoint.command';
Expand Down Expand Up @@ -77,6 +65,12 @@ import { GetNotificationsRequestDto } from './dtos/get-notifications-request.dto
import { GetNotificationsResponseDto } from './dtos/get-notifications-response.dto';
import { GetPreferencesRequestDto } from './dtos/get-preferences-request.dto';
import { GetPreferencesResponseDto } from './dtos/get-preferences-response.dto';
import {
InboxChannelConnectionResponseDto,
InboxListChannelConnectionsResponseDto,
} from './dtos/inbox-channel-connection-response.dto';
import { InboxListChannelEndpointsResponseDto } from './dtos/inbox-channel-endpoint-response.dto';
import { mapChannelConnectionToInboxDto, mapChannelEndpointToInboxDto } from './dtos/inbox-dto.mapper';
import { InboxNotificationDto } from './dtos/inbox-notification.dto';
import { MarkNotificationsAsSeenRequestDto } from './dtos/mark-notifications-as-seen-request.dto';
import { SnoozeNotificationRequestDto } from './dtos/snooze-notification-request.dto';
Expand Down Expand Up @@ -139,11 +133,9 @@ export class InboxController {
private deleteAllNotificationsUsecase: DeleteAllNotifications,
private listChannelConnectionsUsecase: ListChannelConnections,
private getChannelConnectionUsecase: GetChannelConnection,
private createChannelConnectionUsecase: CreateChannelConnection,
private deleteChannelConnectionUsecase: DeleteChannelConnection,
private listChannelEndpointsUsecase: ListChannelEndpoints,
private getChannelEndpointUsecase: GetChannelEndpoint,
private createChannelEndpointUsecase: CreateChannelEndpoint,
private deleteChannelEndpointUsecase: DeleteChannelEndpoint,
private generateChatOauthUrlUsecase: GenerateChatOauthUrl,
private featureFlagsService: FeatureFlagsService
Expand Down Expand Up @@ -674,7 +666,7 @@ export class InboxController {
async listChannelConnections(
@SubscriberSession() subscriberSession: SubscriberSession,
@Query() query: ListChannelConnectionsQueryDto
): Promise<ListChannelConnectionsResponseDto> {
): Promise<InboxListChannelConnectionsResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const result = await this.listChannelConnectionsUsecase.execute(
Expand All @@ -698,11 +690,9 @@ export class InboxController {
);

return {
data: result.data.map(mapChannelConnectionEntityToDto),
next: result.next,
previous: result.previous,
totalCount: result.totalCount!,
totalCountCapped: result.totalCountCapped!,
data: result.data.map(mapChannelConnectionToInboxDto),
next: result.next ?? null,
previous: result.previous ?? null,
};
Comment thread
djabarovgeorge marked this conversation as resolved.
}

Expand All @@ -711,7 +701,7 @@ export class InboxController {
async getChannelConnection(
@SubscriberSession() subscriberSession: SubscriberSession,
@Param('identifier') identifier: string
): Promise<GetChannelConnectionResponseDto> {
): Promise<InboxChannelConnectionResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const channelConnection = await this.getChannelConnectionUsecase.execute(
Expand All @@ -726,31 +716,7 @@ export class InboxController {
throw new NotFoundException(`Channel connection not found: ${identifier}`);
}

return mapChannelConnectionEntityToDto(channelConnection);
}

@UseGuards(AuthGuard('subscriberJwt'))
@Post('/channel-connections')
async createChannelConnection(
@SubscriberSession() subscriberSession: SubscriberSession,
@Body() body: CreateChannelConnectionRequestDto
): Promise<GetChannelConnectionResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const channelConnection = await this.createChannelConnectionUsecase.execute(
CreateChannelConnectionCommand.create({
environmentId: subscriberSession._environmentId,
organizationId: subscriberSession._organizationId,
identifier: body.identifier,
integrationIdentifier: body.integrationIdentifier,
subscriberId: subscriberSession.subscriberId,
context: body.context,
workspace: body.workspace,
auth: body.auth,
})
);

return mapChannelConnectionEntityToDto(channelConnection);
return mapChannelConnectionToInboxDto(channelConnection);
}

@UseGuards(AuthGuard('subscriberJwt'))
Expand Down Expand Up @@ -788,7 +754,7 @@ export class InboxController {
async listChannelEndpoints(
@SubscriberSession() subscriberSession: SubscriberSession,
@Query() query: ListChannelEndpointsQueryDto
): Promise<ListChannelEndpointsResponseDto> {
): Promise<InboxListChannelEndpointsResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const result = await this.listChannelEndpointsUsecase.execute(
Expand All @@ -813,66 +779,12 @@ export class InboxController {
);

return {
data: result.data.map(mapChannelEndpointEntityToDto),
next: result.next,
previous: result.previous,
totalCount: result.totalCount!,
totalCountCapped: result.totalCountCapped!,
data: result.data.map(mapChannelEndpointToInboxDto),
next: result.next ?? null,
previous: result.previous ?? null,
};
}

@UseGuards(AuthGuard('subscriberJwt'))
@Get('/channel-endpoints/:identifier')
async getChannelEndpoint(
@SubscriberSession() subscriberSession: SubscriberSession,
@Param('identifier') identifier: string
): Promise<GetChannelEndpointResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

const channelEndpoint = await this.getChannelEndpointUsecase.execute(
GetChannelEndpointCommand.create({
environmentId: subscriberSession._environmentId,
organizationId: subscriberSession._organizationId,
identifier,
})
);

if (channelEndpoint.subscriberId && channelEndpoint.subscriberId !== subscriberSession.subscriberId) {
throw new NotFoundException(`Channel endpoint not found: ${identifier}`);
}

return mapChannelEndpointEntityToDto(channelEndpoint);
}

@UseGuards(AuthGuard('subscriberJwt'))
@Post('/channel-endpoints')
async createChannelEndpoint(
@SubscriberSession() subscriberSession: SubscriberSession,
@Body() body: CreateChannelEndpointRequest
): Promise<GetChannelEndpointResponseDto> {
await this.checkChannelFeatureEnabled(subscriberSession._organizationId);

// Cast needed because CreateChannelEndpointRequest is a discriminated union; the type/endpoint
// fields are correctly validated by class-validator before reaching this handler.
const typedBody = body as Extract<CreateChannelEndpointRequest, { type: (typeof body)['type'] }>;

const channelEndpoint = await this.createChannelEndpointUsecase.execute(
CreateChannelEndpointCommand.create({
environmentId: subscriberSession._environmentId,
organizationId: subscriberSession._organizationId,
identifier: typedBody.identifier,
integrationIdentifier: typedBody.integrationIdentifier,
connectionIdentifier: typedBody.connectionIdentifier,
subscriberId: subscriberSession.subscriberId,
context: typedBody.context,
type: typedBody.type,
endpoint: typedBody.endpoint,
} as Parameters<typeof CreateChannelEndpointCommand.create>[0])
);

return mapChannelEndpointEntityToDto(channelEndpoint);
}

@UseGuards(AuthGuard('subscriberJwt'))
@Delete('/channel-endpoints/:identifier')
@HttpCode(HttpStatus.NO_CONTENT)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,16 @@ export class GenerateSlackOauthUrl {
await this.assertResourceExists(command);

const { clientId } = await this.getIntegrationCredentials(command.integration);
const subscriberId = command.connectionMode === 'shared' ? undefined : command.subscriberId;
const secureState = await this.createSecureState(
command.integration,
subscriberId,
command.subscriberId,
command.context,
command.connectionIdentifier,
command.mode,
command.connectionMode
);
Comment on lines 65 to 72
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.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect subscriberId constraints and OAuth state delimiter parsing.

rg -n -C4 --type=ts '\bsubscriberId\b|decoded\.split\('\''\.'\''\)'

Repository: novuhq/novu

Length of output: 50369


🏁 Script executed:

#!/bin/bash
# Get the exact file and lines mentioned in the review
file_path="apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts"

# Check file exists and show relevant lines
if [ -f "$file_path" ]; then
  echo "=== File context around lines 65-72 ==="
  sed -n '60,75p' "$file_path"
  echo ""
  echo "=== File context around lines 197-202 ==="
  sed -n '190,210p' "$file_path"
else
  echo "File not found: $file_path"
  # Try to find the file with a flexible search
  find . -name "*generate-slack-oauth-url.usecase.ts" -type f
fi

Repository: novuhq/novu

Length of output: 1492


🏁 Script executed:

#!/bin/bash
# Search for the split('.') pattern in the usecase file and related state handling
rg -n "split\('\.'\)|createSecureState|validateAndDecodeState" apps/api/src/app/integrations --type=ts -A 5 -B 2 | head -150

Repository: novuhq/novu

Length of output: 14838


🏁 Script executed:

#!/bin/bash
# Check if there are any validations on subscriberId format in the codebase
rg -n "subscriberId.*pattern|subscriberId.*validate|subscriberId.*format" apps/api/src --type=ts | head -50

Repository: novuhq/novu

Length of output: 172


🏁 Script executed:

#!/bin/bash
sed -n '159,195p' apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts

Repository: novuhq/novu

Length of output: 1234


🏁 Script executed:

#!/bin/bash
# Check if there's any validation logic for subscriberId format
rg -n "subscriberId" apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts -B 2 -A 2 | head -80

Repository: novuhq/novu

Length of output: 1727


Fix OAuth state parsing to handle subscriber IDs and context with dot delimiters.

Line 200 uses split('.') which fails if subscriberId, context, or connectionIdentifier contain dots (e.g., email-like subscriber IDs, domain names). The payload is JSON that may contain dots; splitting at the first dot instead of the last one breaks signature verification for otherwise valid states.

This issue also affects:

  • apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-msteams-oath-url/generate-msteams-oauth-url.usecase.ts:128
  • Callback handlers in slack-oauth-callback and msteams-oauth-callback
🔒 Proposed fix (use last dot as separator)
-      const [payload, signature] = decoded.split('.');
+      const separatorIndex = decoded.lastIndexOf('.');
+      if (separatorIndex === -1) {
+        throw new Error('Invalid state format');
+      }
+
+      const payload = decoded.slice(0, separatorIndex);
+      const signature = decoded.slice(separatorIndex + 1);
+
+      if (!payload || !signature) {
+        throw new Error('Invalid state format');
+      }
 
       const expectedSignature = createHash(environmentApiKey, payload);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/app/integrations/usecases/generate-chat-oath-url/generate-slack-oath-url/generate-slack-oauth-url.usecase.ts`
around lines 65 - 72, The state encoder/decoder currently splits on '.' which
breaks when subscriberId, context, or connectionIdentifier contain dots; change
the parsing to locate the last '.' (use lastIndexOf('.') on the state string)
and split into payload = state.slice(0, lastIndex) and signature =
state.slice(lastIndex + 1), then JSON.parse the payload and verify the signature
as before. Update the parsing logic wherever split('.') is used (e.g., in the
Slack and MSTEAMS callback handlers and any state parsing functions referenced
by createSecureState and generateSlackOauthUrl/generateMsteamsOauthUrl usecases)
so they use lastIndexOf('.') and handle missing/invalid separators robustly.


const resolvedScope =
command.mode === 'link_user' ? undefined : await this.resolveBotScopes(command);
const resolvedScope = command.mode === 'link_user' ? undefined : await this.resolveBotScopes(command);

return this.getOAuthUrl(clientId!, secureState, resolvedScope, command.userScope, command.mode);
}
Expand Down
17 changes: 0 additions & 17 deletions packages/js/src/channel-connections/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,11 @@ import type { Context } from '../types';

export type ChannelConnectionResponse = {
identifier: string;
integrationIdentifier: string;
providerId: string;
channel: string;
subscriberId?: string;
contextKeys: string[];
workspace: { id: string; name?: string };
createdAt: string;
updatedAt: string;
};

export type ChannelEndpointResponse = {
identifier: string;
integrationIdentifier: string;
connectionIdentifier?: string;
providerId: string;
channel: string;
subscriberId: string;
contextKeys: string[];
type: string;
endpoint: Record<string, string>;
createdAt: string;
updatedAt: string;
};

export type OAuthMode = 'connect' | 'link_user';
Expand Down
Loading