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,111 @@
import { EnvironmentRepository } from '@novu/dal';
import { ApiServiceLevelEnum, EnvironmentEnum } from '@novu/shared';
import { UserSession } from '@novu/testing';
import { expect } from 'chai';

describe('Update Environment API key environment scope - PUT /environments/:environmentId #novu-v2', () => {
let session: UserSession;
const environmentRepository = new EnvironmentRepository();

beforeEach(async () => {
session = new UserSession();
await session.initialize();
await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS);
});

it('should forbid updating a sibling environment via API key', async () => {
const {
body: { data: createdEnv },
} = await session.testAgent.post('/v1/environments').send({
name: 'Sibling Env To Update',
color: '#ff0000',
});

expect(createdEnv._id, 'Expected custom environment to be created').to.exist;

const { body } = await session.testAgent
.put(`/v1/environments/${createdEnv._id}`)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({
identifier: 'compromised-sibling',
color: '#0000ff',
bridge: { url: 'https://attacker.example/api/novu' },
});

expect(body.statusCode).to.equal(403);
expect(body.message).to.contain('is scoped to a single environment');

const stored = await environmentRepository.findOne({ _id: createdEnv._id });
expect(stored?.identifier).to.not.equal('compromised-sibling');
expect(stored?.color).to.not.equal('#0000ff');
expect(stored?.bridge?.url || stored?.echo?.url || '').to.not.equal('https://attacker.example/api/novu');
});

it('should forbid updating Production via a Development API key', async () => {
const {
body: { data: environments },
} = await session.testAgent.get('/v1/environments');
const production = environments.find(
(environment: { name: string }) => environment.name === EnvironmentEnum.PRODUCTION
);

expect(production?._id, 'Expected Production environment').to.exist;
expect(production._id).to.not.equal(session.environment._id);

const original = await environmentRepository.findOne({ _id: production._id });

const { body } = await session.testAgent
.put(`/v1/environments/${production._id}`)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({
identifier: 'compromised-production',
bridge: { url: 'https://attacker.example/api/novu' },
});

expect(body.statusCode).to.equal(403);
expect(body.message).to.contain('is scoped to a single environment');

const stored = await environmentRepository.findOne({ _id: production._id });
expect(stored?.identifier).to.equal(original?.identifier);
expect(stored?.bridge?.url || '').to.equal(original?.bridge?.url || '');
expect(stored?.echo?.url || '').to.equal(original?.echo?.url || '');
});

it('should allow an API key to update its own environment', async () => {
const { status } = await session.testAgent
.put(`/v1/environments/${session.environment._id}`)
.set('authorization', `ApiKey ${session.apiKey}`)
.send({
identifier: 'own-env-via-api-key',
color: '#3366ff',
});

expect(status).to.equal(200);

const stored = await environmentRepository.findOne({ _id: session.environment._id });
expect(stored?.identifier).to.equal('own-env-via-api-key');
expect(stored?.color).to.equal('#3366ff');
});

it('should allow bearer auth to update a sibling environment in the same org', async () => {
const {
body: { data: createdEnv },
} = await session.testAgent.post('/v1/environments').send({
name: 'Bearer Updatable Env',
color: '#00ff00',
});

expect(createdEnv._id, 'Expected custom environment to be created').to.exist;

const { status } = await session.testAgent.put(`/v1/environments/${createdEnv._id}`).send({
identifier: 'bearer-updated-sibling',
color: '#112233',
});

expect(status).to.equal(200);

const stored = await environmentRepository.findOne({ _id: createdEnv._id });
expect(stored?.identifier).to.equal('bearer-updated-sibling');
expect(stored?.color).to.equal('#112233');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { ApiKey } from '../shared/dtos/api-key';
import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator';
import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators';
import { UserSession } from '../shared/framework/user.decorator';
import { isEnvironmentScopedAuthScheme } from '../shared/utils/auth.utils';
import { assertEnvironmentScopedAccess, isEnvironmentScopedAuthScheme } from '../shared/utils/auth.utils';
import { CreateEnvironmentRequestDto } from './dtos/create-environment-request.dto';
import { EnvironmentResponseDto } from './dtos/environment-response.dto';
import { UpdateEnvironmentRequestDto } from './dtos/update-environment-request.dto';
Expand Down Expand Up @@ -190,6 +190,8 @@ export class EnvironmentsControllerV1 {
@Param('environmentId') environmentId: string,
@Body() payload: UpdateEnvironmentRequestDto
) {
assertEnvironmentScopedAccess(user.scheme, user.environmentId, environmentId);

return await this.updateEnvironmentUsecase.execute(
UpdateEnvironmentCommand.create({
environmentId,
Expand Down
1 change: 1 addition & 0 deletions docs/agents.mdx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
title: "Novu Connect - Agent Communication Infrastructure (ACI)"
description: "Connect AI agents to Slack, Teams, WhatsApp, Telegram, and email. Route inbound messages, preserve conversation context, and deliver replies through Novu ACI."
sidebarTitle: Overview
Expand Down
4 changes: 0 additions & 4 deletions docs/agents/channels/web-chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ description: "Connect your agent to in-app Web Chat: a headless React hook, live
sidebarTitle: Overview
---

<Note>
Web Chat is in closed beta. Contact us at support@novu.co to get access.
</Note>

Web Chat is an ACI channel. The messaging surface is your product UI.

The agent brain, conversation history, tool approval, and dashboard observability match other ACI channels. The React client is [`useWebChat`](/platform/sdks/react/hooks/use-web-chat) from `@novu/react`. You render the message list and the composer. There is no prebuilt `<WebChat />` component.
Expand Down
4 changes: 0 additions & 4 deletions docs/agents/channels/web-chat/chat-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ description: "Web Chat client: state, parts, thinking, cards, approvals, custom
sidebarTitle: Chat UI
---

<Note>
Web Chat is in closed beta. Contact us at support@novu.co to get access.
</Note>

Start from [Send a message](/agents/channels/web-chat/quickstart#send-a-message). This page covers everything after the first message.

`messages` is the ordered timeline. Each message has `role` (`user` or `assistant`) and `parts`.
Expand Down
4 changes: 0 additions & 4 deletions docs/agents/channels/web-chat/quickstart.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ description: "Install @novu/react, wrap NovuProvider, and use useWebChat to send
sidebarTitle: Quickstart
---

<Note>
Web Chat is in closed beta. Contact us at support@novu.co to get access.
</Note>

Build a two-way Web Chat in your React app. You supply the UI. Novu supplies conversation state, delivery, and the live socket.

## Prerequisites
Expand Down
51 changes: 51 additions & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@
"agents/channels/email",
{
"group": "Web Chat",
"tag": "Beta",
"root": "agents/channels/web-chat",
"pages": ["agents/channels/web-chat/quickstart", "agents/channels/web-chat/chat-ui"]
}
Expand Down Expand Up @@ -1721,36 +1722,86 @@
"destination": "/agents/channels/overview",
"permanent": true
},
{
"source": "/platform/integrations/chat/agent-chat",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/platform/integrations/chat/web-chat",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/agents/agent-chat",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/agents/web-chat",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/agents/agent-chat/quickstart",
"destination": "/agents/channels/web-chat/quickstart",
"permanent": true
},
{
"source": "/agents/web-chat/quickstart",
"destination": "/agents/channels/web-chat/quickstart",
"permanent": true
},
{
"source": "/agents/channels/agent-chat",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/agents/channels/agent-chat/quickstart",
"destination": "/agents/channels/web-chat/quickstart",
"permanent": true
},
{
"source": "/agents/channels/agent-chat/chat-ui",
"destination": "/agents/channels/web-chat/chat-ui",
"permanent": true
},
{
"source": "/agents/channels/agent-chat/rendering",
"destination": "/agents/channels/web-chat/chat-ui",
"permanent": true
},
{
"source": "/agents/channels/web-chat/rendering",
"destination": "/agents/channels/web-chat/chat-ui",
"permanent": true
},
{
"source": "/agents/channels/agent-chat/production",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/agents/channels/web-chat/production",
"destination": "/agents/channels/web-chat",
"permanent": true
},
{
"source": "/agents/channels/agent-chat/messages",
"destination": "/agents/channels/web-chat/chat-ui",
"permanent": true
},
{
"source": "/agents/channels/web-chat/messages",
"destination": "/agents/channels/web-chat/chat-ui",
"permanent": true
},
{
"source": "/platform/sdks/react/hooks/use-agent-chat",
"destination": "/platform/sdks/react/hooks/use-web-chat",
"permanent": true
},
{
"source": "/agents/custom-code-agent/build-your-first-agent",
"destination": "/agents/custom-code-agent/frameworks/ai-sdk",
Expand Down
5 changes: 1 addition & 4 deletions docs/platform/sdks/react/hooks/use-web-chat.mdx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
---
title: "useWebChat"
description: "API reference for the useWebChat hook: conversation state, sendMessage, retry, tool approval, live typing, and history pagination in the Novu React SDK."
tag: Beta
---

<Note>
Web Chat is in closed beta. Contact us at support@novu.co to get access.
</Note>

The `useWebChat` hook is the headless client for [Web Chat](/agents/channels/web-chat). It loads conversation history, sends messages, streams live turns over the socket, and exposes pending tool approvals.

Use it inside [`NovuProvider`](/platform/sdks/react/hooks/novu-provider). See [Quickstart](/agents/channels/web-chat/quickstart), [Chat UI](/agents/channels/web-chat/chat-ui), and [State](/agents/channels/web-chat/chat-ui#state).
Expand Down
36 changes: 36 additions & 0 deletions libs/application-generic/src/utils/sanitize-control-values.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,40 @@ describe('dashboardSanitizeControlValues', () => {

expect(sanitized).not.toHaveProperty('editorType');
});

it('omits an empty throttle dynamicKey for a fixed throttle', () => {
const sanitized = dashboardSanitizeControlValues(
logger,
{ type: 'fixed', amount: 1, unit: 'hours', dynamicKey: '', threshold: 1, throttleKey: '{{payload.alertKey}}' },
StepTypeEnum.THROTTLE
);

expect(sanitized).toEqual({
type: 'fixed',
amount: 1,
unit: 'hours',
threshold: 1,
throttleKey: '{{payload.alertKey}}',
});
});

it('keeps an empty throttle dynamicKey for a dynamic throttle so the issue still surfaces', () => {
const sanitized = dashboardSanitizeControlValues(
logger,
{ type: 'dynamic', dynamicKey: '', threshold: 1 },
StepTypeEnum.THROTTLE
);

expect(sanitized).toEqual({ type: 'dynamic', dynamicKey: '', threshold: 1 });
});

it('keeps a populated throttle dynamicKey regardless of type', () => {
const sanitized = dashboardSanitizeControlValues(
logger,
{ type: 'fixed', amount: 1, unit: 'hours', dynamicKey: 'payload.timestamp' },
StepTypeEnum.THROTTLE
);

expect(sanitized).toEqual({ type: 'fixed', amount: 1, unit: 'hours', dynamicKey: 'payload.timestamp' });
});
});
16 changes: 16 additions & 0 deletions libs/application-generic/src/utils/sanitize-control-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
LookBackWindowType,
PushControlType,
SmsControlType,
ThrottleControlType,
ToolControlType,
} from '../schemas/control';
import { InAppActionType, InAppControlType } from '../schemas/control/in-app-control.schema';
Expand Down Expand Up @@ -253,6 +254,18 @@ function sanitizeDelay(controlValues: DelayControlType) {
return filterNullishValues(controlValues);
}

/**
* A fixed throttle never reads `dynamicKey`, but the dashboard form still persists it as an empty
* string. The control schema keeps `dynamicKey` optional with `minLength: 1`, so a present-but-empty
* value fails validation and surfaces a "DynamicKey is required" issue on a correctly configured
* fixed throttle. Drop the unused key; a dynamic throttle keeps it so the issue still surfaces there.
*/
function sanitizeThrottle(controlValues: ThrottleControlType) {
const shouldDropDynamicKey = controlValues?.type !== 'dynamic' && isEmpty(controlValues?.dynamicKey);

return filterNullishValues(shouldDropDynamicKey ? { ...controlValues, dynamicKey: undefined } : controlValues);
}

function sanitizeLayout(controlValues: LayoutControlType) {
return {
email: filterNullishValues({
Expand Down Expand Up @@ -361,6 +374,9 @@ export function dashboardSanitizeControlValues(
case StepTypeEnum.DELAY:
normalizedValues = sanitizeDelay(controlValues as DelayControlType);
break;
case StepTypeEnum.THROTTLE:
normalizedValues = sanitizeThrottle(controlValues as ThrottleControlType);
break;
case 'layout':
normalizedValues = sanitizeLayout(controlValues as LayoutControlType);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,41 @@ describe('scaffoldWebChatProject', () => {
})
).rejects.toThrow(/Invalid scaffold directory name/);
});

it('does not hardcode localhost when merging Web Chat into an existing project', async () => {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'novu-web-chat-merge-'));
fs.writeFileSync(
path.join(projectDir, 'package.json'),
JSON.stringify(
{
name: 'agent-app',
dependencies: {
'@novu/react': 'latest',
'@novu/js': 'latest',
'react-markdown': '^10.1.0',
'remark-gfm': '^4.0.1',
},
},
null,
2
)
);

await scaffoldWebChatProject({
parentDir: projectDir,
agentIdentifier: 'support-agent',
applicationIdentifier: 'app-id',
subscriberId: 'subscriber-id',
apiUrl: 'https://api.novu.co',
mergeIntoProjectDir: projectDir,
mergeAtRoot: true,
});

const page = fs.readFileSync(path.join(projectDir, 'app', 'page.tsx'), 'utf8');
expect(page).not.toContain('localhost:3000');
expect(page).toContain('...(apiUrl ? { apiUrl } : {})');
expect(page).toContain('...(socketUrl ? { socketUrl } : {})');
});
});

describe('resolveWebChatNovuDependencies', () => {
Expand Down
Loading
Loading