Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6273ba2
feat(dal, api-service): per-agent bridge URL data model and resolutio…
ChmaraX Apr 15, 2026
2260663
feat(api-service): agent bridge URL API with production environment g…
ChmaraX Apr 15, 2026
a4a96b2
feat(framework): include agents in Client.discover() output NV-7373
ChmaraX Apr 15, 2026
cfc1c83
feat(novu): agent discovery and dev bridge registration in npx novu d…
ChmaraX Apr 15, 2026
d05f158
feat(novu): set per-agent production bridgeUrl during npx novu sync N…
ChmaraX Apr 15, 2026
964e929
feat(dashboard): bridge URL config section and DEV badge on agent UI …
ChmaraX Apr 15, 2026
fab83ef
test: add e2e tests for bridge URL management, prod guard, and framew…
ChmaraX Apr 15, 2026
ed6799c
fix(framework): add jsx-dev-runtime export for Next.js dev mode NV-7373
ChmaraX Apr 16, 2026
952af9a
feat: --agent-identifier CLI flag, JSX card serialization, --no-studi…
ChmaraX Apr 16, 2026
5337078
fix(novu): load .env.local for secret key and fix --no-studio flag NV…
ChmaraX Apr 16, 2026
c7fbb4c
merge: resolve conflicts with origin/next
ChmaraX Apr 16, 2026
f6a7ad2
test(framework): add JSX Card element serialization test NV-7373
ChmaraX Apr 16, 2026
2c0756e
Merge branch 'next' into nv-7373-per-agent-bridge-url-with-devproduct…
ChmaraX Apr 16, 2026
4dcec79
fix: address review feedback for per-agent bridge URL feature
ChmaraX Apr 16, 2026
2a86554
merge: resolve conflicts with origin/next
ChmaraX Apr 16, 2026
ad91c11
fix: address second round of review feedback
ChmaraX Apr 16, 2026
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: 1 addition & 1 deletion .source
36 changes: 36 additions & 0 deletions apps/api/src/app/agents/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
UseInterceptors,
Expand All @@ -17,6 +18,7 @@ import { ApiExcludeController, ApiOperation } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ThrottlerCategory } from '../rate-limiting/guards';
import {
ApiCommonResponses,
Expand All @@ -34,6 +36,7 @@ import {
ListAgentIntegrationsResponseDto,
ListAgentsQueryDto,
ListAgentsResponseDto,
UpdateAgentBridgeRequestDto,
UpdateAgentIntegrationRequestDto,
UpdateAgentRequestDto,
} from './dtos';
Expand Down Expand Up @@ -240,6 +243,36 @@ export class AgentsController {
);
}

@Put('/:identifier/bridge')
@ApiResponse(AgentResponseDto)
@ApiOperation({
summary: 'Update agent bridge configuration',
description:
'Updates the bridge URL configuration for an agent. Used by the CLI to register dev tunnel URLs. Refuses to activate dev bridges on production environments.',
})
@ApiNotFoundResponse({
description: 'The agent was not found.',
})
@ExternalApiAccessible()
@RequirePermissions(PermissionsEnum.AGENT_WRITE)
updateAgentBridge(
@UserSession() user: UserSessionData,
@Param('identifier') identifier: string,
@Body() body: UpdateAgentBridgeRequestDto
): Promise<AgentResponseDto> {
return this.updateAgentUsecase.execute(
UpdateAgentCommand.create({
userId: user._id,
environmentId: user.environmentId,
organizationId: user.organizationId,
identifier,
bridgeUrl: body.bridgeUrl,
devBridgeUrl: body.devBridgeUrl,
devBridgeActive: body.devBridgeActive,
})
);
}

@Get('/:identifier')
@ApiResponse(AgentResponseDto)
@ApiOperation({
Expand Down Expand Up @@ -285,6 +318,9 @@ export class AgentsController {
description: body.description,
active: body.active,
behavior: body.behavior,
bridgeUrl: body.bridgeUrl,
devBridgeUrl: body.devBridgeUrl,
devBridgeActive: body.devBridgeActive,
})
);
}
Expand Down
9 changes: 9 additions & 0 deletions apps/api/src/app/agents/dtos/agent-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ export class AgentResponseDto {
@ApiProperty()
active: boolean;

@ApiPropertyOptional({ description: 'Production bridge URL' })
bridgeUrl?: string;

@ApiPropertyOptional({ description: 'Development bridge URL (set by npx novu dev)' })
devBridgeUrl?: string;

@ApiPropertyOptional({ description: 'Whether the dev bridge override is active' })
devBridgeActive?: boolean;

@ApiProperty()
_environmentId: string;

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/app/agents/dtos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from './list-agent-integrations-response.dto';
export * from './list-agents-query.dto';
export * from './list-agents-response.dto';
export * from './update-agent-integration-request.dto';
export * from './update-agent-bridge-request.dto';
export * from './update-agent-request.dto';
19 changes: 19 additions & 0 deletions apps/api/src/app/agents/dtos/update-agent-bridge-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional, IsUrl } from 'class-validator';

export class UpdateAgentBridgeRequestDto {
@ApiPropertyOptional({ description: 'Production bridge URL for this agent' })
@IsUrl({ require_tld: false })
@IsOptional()
bridgeUrl?: string;

@ApiPropertyOptional({ description: 'Development bridge URL (set by npx novu dev)' })
@IsUrl({ require_tld: false })
@IsOptional()
devBridgeUrl?: string;

@ApiPropertyOptional({ description: 'Whether the dev bridge override is active' })
@IsBoolean()
@IsOptional()
devBridgeActive?: boolean;
}
17 changes: 16 additions & 1 deletion apps/api/src/app/agents/dtos/update-agent-request.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsBoolean, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';

import { AgentBehaviorDto } from './agent-behavior.dto';

Expand All @@ -25,4 +25,19 @@ export class UpdateAgentRequestDto {
@Type(() => AgentBehaviorDto)
@IsOptional()
behavior?: AgentBehaviorDto;

@ApiPropertyOptional({ description: 'Production bridge URL for this agent' })
@IsUrl({ require_tld: false })
@IsOptional()
bridgeUrl?: string;

@ApiPropertyOptional({ description: 'Development bridge URL (set by npx novu dev)' })
@IsUrl({ require_tld: false })
@IsOptional()
devBridgeUrl?: string;

@ApiPropertyOptional({ description: 'Whether the dev bridge override is active' })
@IsBoolean()
@IsOptional()
devBridgeActive?: boolean;
}
137 changes: 137 additions & 0 deletions apps/api/src/app/agents/e2e/agents.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,4 +299,141 @@ describe('Agents API - /agents #novu-v2', () => {

expect(agentAfter).to.equal(null);
});

describe('Bridge URL management', () => {
let identifier: string;

beforeEach(async () => {
identifier = `e2e-bridge-${Date.now()}`;
await session.testAgent.post('/v1/agents').send({ name: 'Bridge Agent', identifier });
});

afterEach(async () => {
await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
});

it('should update bridgeUrl via PATCH', async () => {
const res = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
bridgeUrl: 'https://prod.example.com/api/novu',
});

expect(res.status).to.equal(200);
expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/api/novu');
});

it('should update devBridgeUrl and devBridgeActive via PUT bridge endpoint', async () => {
const res = await session.testAgent.put(`/v1/agents/${encodeURIComponent(identifier)}/bridge`).send({
devBridgeUrl: 'https://tunnel.example.com',
devBridgeActive: true,
});

expect(res.status).to.equal(200);
expect(res.body.data.devBridgeUrl).to.equal('https://tunnel.example.com');
expect(res.body.data.devBridgeActive).to.equal(true);
});

it('should set bridgeUrl via PUT bridge endpoint', async () => {
const res = await session.testAgent.put(`/v1/agents/${encodeURIComponent(identifier)}/bridge`).send({
bridgeUrl: 'https://prod.example.com/novu',
});

expect(res.status).to.equal(200);
expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/novu');
});

it('should return bridge fields on GET', async () => {
await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
bridgeUrl: 'https://prod.example.com/api/novu',
devBridgeUrl: 'https://tunnel.example.com',
devBridgeActive: true,
});

const res = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`);

expect(res.status).to.equal(200);
expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/api/novu');
expect(res.body.data.devBridgeUrl).to.equal('https://tunnel.example.com');
expect(res.body.data.devBridgeActive).to.equal(true);
});

it('should deactivate devBridgeActive', async () => {
await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
devBridgeUrl: 'https://tunnel.example.com',
devBridgeActive: true,
});

const res = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
devBridgeActive: false,
});

expect(res.status).to.equal(200);
expect(res.body.data.devBridgeActive).to.equal(false);
expect(res.body.data.devBridgeUrl).to.equal('https://tunnel.example.com');
});
});

describe('Production environment guard', () => {
let prodSession: UserSession;
let identifier: string;

before(async () => {
prodSession = new UserSession();
await prodSession.initialize();
});

beforeEach(async () => {
identifier = `e2e-prodguard-${Date.now()}`;

await prodSession.switchToDevEnvironment();
await prodSession.testAgent.post('/v1/agents').send({ name: 'Guard Agent', identifier });
});

afterEach(async () => {
await prodSession.switchToDevEnvironment();
await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
});

it('should reject devBridgeActive=true on production environment', async () => {
await prodSession.switchToProdEnvironment();

await prodSession.testAgent.post('/v1/agents').send({ name: 'Prod Agent', identifier: `${identifier}-prod` });

const res = await prodSession.testAgent.patch(`/v1/agents/${encodeURIComponent(`${identifier}-prod`)}`).send({
devBridgeActive: true,
});

expect(res.status).to.equal(403);

await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(`${identifier}-prod`)}`);
});

it('should reject devBridgeUrl on production environment', async () => {
await prodSession.switchToProdEnvironment();

await prodSession.testAgent.post('/v1/agents').send({ name: 'Prod Agent 2', identifier: `${identifier}-prod2` });

const res = await prodSession.testAgent.patch(`/v1/agents/${encodeURIComponent(`${identifier}-prod2`)}`).send({
devBridgeUrl: 'https://tunnel.example.com',
});

expect(res.status).to.equal(403);

await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(`${identifier}-prod2`)}`);
});

it('should allow bridgeUrl on production environment', async () => {
await prodSession.switchToProdEnvironment();

await prodSession.testAgent.post('/v1/agents').send({ name: 'Prod Agent 3', identifier: `${identifier}-prod3` });

const res = await prodSession.testAgent.patch(`/v1/agents/${encodeURIComponent(`${identifier}-prod3`)}`).send({
bridgeUrl: 'https://prod.example.com/novu',
});

expect(res.status).to.equal(200);
expect(res.body.data.bridgeUrl).to.equal('https://prod.example.com/novu');

await prodSession.testAgent.delete(`/v1/agents/${encodeURIComponent(`${identifier}-prod3`)}`);
});
});
});
12 changes: 0 additions & 12 deletions apps/api/src/app/agents/e2e/mock-agent-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,18 +128,6 @@ const echoBot = agent('novu-agent', {
await ctx.reply(`Echo: ${userText}`);
},

onReaction: async (ctx) => {
console.log('\n─────────────────────────────────────────');
console.log(`[${ctx.event}] reaction: ${ctx.reaction?.emoji.name} (${ctx.reaction?.added ? 'added' : 'removed'})`);
console.log(`Reacted message: ${ctx.reaction?.message?.text ?? '(unavailable)'}`);
console.log('─────────────────────────────────────────');

const emoji = ctx.reaction?.emoji.name ?? 'unknown';
const added = ctx.reaction?.added ?? false;

await ctx.reply(`Got ${added ? '' : 'un'}reaction: :${emoji}:`);
},

onAction: async (ctx) => {
console.log('\n─────────────────────────────────────────');
console.log(`[${ctx.event}] action: ${ctx.action?.actionId} = ${ctx.action?.value ?? '(no value)'}`);
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app/agents/mappers/agent-response.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export function toAgentResponse(agent: AgentEntity): AgentResponseDto {
description: agent.description,
active: agent.active,
behavior: agent.behavior,
bridgeUrl: agent.bridgeUrl,
devBridgeUrl: agent.devBridgeUrl,
devBridgeActive: agent.devBridgeActive,
_environmentId: agent._environmentId,
_organizationId: agent._organizationId,
createdAt: agent.createdAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export interface ResolvedAgentConfig {
thinkingIndicatorEnabled: boolean;
reactionOnMessageReceived: string | null;
reactionOnResolved: string | null;
bridgeUrl?: string;
devBridgeUrl?: string;
devBridgeActive?: boolean;
}

const DEFAULT_REACTION_ON_MESSAGE = 'eyes';
Expand Down Expand Up @@ -135,6 +138,9 @@ export class AgentConfigResolver {
DEFAULT_REACTION_ON_MESSAGE
),
reactionOnResolved: resolveReaction(agent.behavior?.reactions?.onResolved, DEFAULT_REACTION_ON_RESOLVED),
bridgeUrl: agent.bridgeUrl,
devBridgeUrl: agent.devBridgeUrl,
devBridgeActive: agent.devBridgeActive,
};
}
}
30 changes: 12 additions & 18 deletions apps/api/src/app/agents/services/bridge-executor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
ConversationActivitySenderTypeEnum,
ConversationActivityTypeEnum,
ConversationEntity,
EnvironmentRepository,
SubscriberEntity,
} from '@novu/dal';
import type { Message } from 'chat';
Expand Down Expand Up @@ -129,7 +128,6 @@ export class NoBridgeUrlError extends Error {
@Injectable()
export class BridgeExecutorService {
constructor(
private readonly environmentRepository: EnvironmentRepository,
private readonly getDecryptedSecretKey: GetDecryptedSecretKey,
private readonly logger: PinoLogger
) {}
Expand All @@ -140,12 +138,7 @@ export class BridgeExecutorService {
try {
const { config, event } = params;

const bridgeUrl = await this.resolveBridgeUrl(
config.environmentId,
config.organizationId,
agentIdentifier,
event
);
const bridgeUrl = this.resolveBridgeUrl(config, agentIdentifier, event);
if (!bridgeUrl) {
throw new NoBridgeUrlError(agentIdentifier);
}
Expand Down Expand Up @@ -219,20 +212,21 @@ export class BridgeExecutorService {
});
}

private async resolveBridgeUrl(
environmentId: string,
organizationId: string,
private resolveBridgeUrl(
config: ResolvedAgentConfig,
agentIdentifier: string,
event: AgentEventEnum
): Promise<string | null> {
const environment = await this.environmentRepository.findOne(
{ _id: environmentId, _organizationId: organizationId },
['bridge']
);
const baseUrl = environment?.bridge?.url;
): string | null {
let baseUrl: string | undefined;

if (config.devBridgeActive && config.devBridgeUrl) {
baseUrl = config.devBridgeUrl;
} else if (config.bridgeUrl) {
baseUrl = config.bridgeUrl;
}

if (!baseUrl) {
this.logger.warn(`[agent:${agentIdentifier}] No bridge URL configured on environment, skipping bridge call`);
this.logger.warn(`[agent:${agentIdentifier}] No bridge URL configured on agent, skipping bridge call`);

return null;
}
Expand Down
Loading
Loading