diff --git a/apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts b/apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts new file mode 100644 index 00000000000..e8cae5d6b07 --- /dev/null +++ b/apps/api/src/app/environments-v1/e2e/update-environment-api-key-scope.e2e.ts @@ -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'); + }); +}); diff --git a/apps/api/src/app/environments-v1/environments-v1.controller.ts b/apps/api/src/app/environments-v1/environments-v1.controller.ts index 7a9f1eaa6b8..9ee67580128 100644 --- a/apps/api/src/app/environments-v1/environments-v1.controller.ts +++ b/apps/api/src/app/environments-v1/environments-v1.controller.ts @@ -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'; @@ -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, diff --git a/docs/agents.mdx b/docs/agents.mdx index 8afaacd2c7b..e2804f8782c 100644 --- a/docs/agents.mdx +++ b/docs/agents.mdx @@ -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 diff --git a/docs/agents/channels/web-chat.mdx b/docs/agents/channels/web-chat.mdx index b42ca809cb6..8424300b328 100644 --- a/docs/agents/channels/web-chat.mdx +++ b/docs/agents/channels/web-chat.mdx @@ -4,10 +4,6 @@ description: "Connect your agent to in-app Web Chat: a headless React hook, live sidebarTitle: Overview --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - 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 `` component. diff --git a/docs/agents/channels/web-chat/chat-ui.mdx b/docs/agents/channels/web-chat/chat-ui.mdx index a4911707c5b..ec80883f76d 100644 --- a/docs/agents/channels/web-chat/chat-ui.mdx +++ b/docs/agents/channels/web-chat/chat-ui.mdx @@ -4,10 +4,6 @@ description: "Web Chat client: state, parts, thinking, cards, approvals, custom sidebarTitle: Chat UI --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - 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`. diff --git a/docs/agents/channels/web-chat/quickstart.mdx b/docs/agents/channels/web-chat/quickstart.mdx index 9494c254d03..1992e044042 100644 --- a/docs/agents/channels/web-chat/quickstart.mdx +++ b/docs/agents/channels/web-chat/quickstart.mdx @@ -4,10 +4,6 @@ description: "Install @novu/react, wrap NovuProvider, and use useWebChat to send sidebarTitle: Quickstart --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - Build a two-way Web Chat in your React app. You supply the UI. Novu supplies conversation state, delivery, and the live socket. ## Prerequisites diff --git a/docs/docs.json b/docs/docs.json index 1d2154cdad0..17e5be79059 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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"] } @@ -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", diff --git a/docs/platform/sdks/react/hooks/use-web-chat.mdx b/docs/platform/sdks/react/hooks/use-web-chat.mdx index e112f3316af..0ba37b95631 100644 --- a/docs/platform/sdks/react/hooks/use-web-chat.mdx +++ b/docs/platform/sdks/react/hooks/use-web-chat.mdx @@ -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 --- - - Web Chat is in closed beta. Contact us at support@novu.co to get access. - - 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). diff --git a/libs/application-generic/src/utils/sanitize-control-values.spec.ts b/libs/application-generic/src/utils/sanitize-control-values.spec.ts index f0ffcdf09b5..2cf6fef2783 100644 --- a/libs/application-generic/src/utils/sanitize-control-values.spec.ts +++ b/libs/application-generic/src/utils/sanitize-control-values.spec.ts @@ -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' }); + }); }); diff --git a/libs/application-generic/src/utils/sanitize-control-values.ts b/libs/application-generic/src/utils/sanitize-control-values.ts index 6e693820f6d..8195311ec41 100644 --- a/libs/application-generic/src/utils/sanitize-control-values.ts +++ b/libs/application-generic/src/utils/sanitize-control-values.ts @@ -16,6 +16,7 @@ import { LookBackWindowType, PushControlType, SmsControlType, + ThrottleControlType, ToolControlType, } from '../schemas/control'; import { InAppActionType, InAppControlType } from '../schemas/control/in-app-control.schema'; @@ -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({ @@ -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; diff --git a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts index ab33e202dfc..3adff568e14 100644 --- a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts +++ b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.spec.ts @@ -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', () => { diff --git a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts index df554c30af2..7629de6198d 100644 --- a/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts +++ b/packages/novu/src/commands/connect/pipeline/web-chat/scaffold-web-chat.ts @@ -246,11 +246,16 @@ const inter = Inter({ subsets: ['latin'], display: 'swap' }); export default function WebChatPage() { const applicationIdentifier = process.env.NEXT_PUBLIC_NOVU_APP_ID ?? ''; const subscriberId = process.env.NEXT_PUBLIC_NOVU_SUBSCRIBER_ID ?? ''; - const apiUrl = process.env.NEXT_PUBLIC_NOVU_BACKEND_URL ?? 'http://localhost:3000'; + const apiUrl = process.env.NEXT_PUBLIC_NOVU_BACKEND_URL; const socketUrl = process.env.NEXT_PUBLIC_NOVU_SOCKET_URL; return ( - +