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
2 changes: 2 additions & 0 deletions apps/api/src/app/connect/connect.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ConversationActivityRepository,
ConversationRepository,
EnvironmentRepository,
HumanInteractionRepository,
IntegrationRepository,
McpConnectionRepository,
SubscriberRepository,
Expand Down Expand Up @@ -35,6 +36,7 @@ import { ClaimKeylessConnect } from './usecases/claim-keyless-connect/claim-keyl
AgentMcpServerRepository,
McpConnectionRepository,
EnvironmentRepository,
HumanInteractionRepository,
],
exports: [ConnectClaimTokenService],
})
Expand Down
24 changes: 24 additions & 0 deletions apps/api/src/app/connect/services/connect-claim-token.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,30 @@ export class ConnectClaimTokenService {
return issued;
}

/**
* True once the claim token issued for this keyless environment has been
* consumed — i.e. its assets now live in a real organization. Best-effort:
* cache outages and missing tokens read as "not claimed".
*/
async isEnvironmentClaimed(environmentId: string): Promise<boolean> {
if (!this.cacheService.cacheEnabled()) {
return false;
}

try {
const token = await this.cacheService.get(`${ENV_TOKEN_KEY_PREFIX}{${environmentId}}`);
if (!token || !isConnectClaimTokenFormat(token)) {
return false;
}

return await this.tokens.isTokenUsed(token);
} catch (error) {
this.logger.warn({ err: error, environmentId }, 'Failed to read connect claim state for environment');

return false;
}
}

async isSignupCtaPosted(conversationId: string): Promise<boolean> {
if (!this.cacheService.cacheEnabled()) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ConversationRepository,
EnvironmentEntity,
EnvironmentRepository,
HumanInteractionRepository,
IntegrationRepository,
McpConnectionRepository,
SubscriberRepository,
Expand Down Expand Up @@ -49,6 +50,7 @@ export class ClaimKeylessConnect {
private readonly subscriberRepository: SubscriberRepository,
private readonly agentMcpServerRepository: AgentMcpServerRepository,
private readonly mcpConnectionRepository: McpConnectionRepository,
private readonly humanInteractionRepository: HumanInteractionRepository,
private readonly logger: PinoLogger
) {
this.logger.setContext(this.constructor.name);
Expand Down Expand Up @@ -106,6 +108,7 @@ export class ClaimKeylessConnect {
await this.conversationActivityRepository.update(sourceScope, { $set: target }, { session });
await this.agentMcpServerRepository.update(sourceScope, { $set: target }, { session });
await this.mcpConnectionRepository.update(sourceScope, { $set: target }, { session });
await this.humanInteractionRepository.update(sourceScope, { $set: target }, { session });
await this.subscriberRepository.update(
{ ...sourceScope, subscriberId: { $ne: KEYLESS_SUBSCRIBER_ID } },
{ $set: target },
Expand Down
95 changes: 95 additions & 0 deletions apps/api/src/app/human/e2e/human-interactions.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ChatInstanceRegistry } from '../../agents/conversation-runtime/ingress/
import { AgentInboundHandler } from '../../agents/conversation-runtime/ingress/inbound-turn.handler';
import { startTelegramApiStub, type TelegramApiStub } from '../../agents/e2e/helpers/telegram-api-stub';
import { AgentEventEnum } from '../../agents/shared/enums/agent-event.enum';
import { ConnectClaimTokenService } from '../../connect/services/connect-claim-token.service';

const integrationRepository = new IntegrationRepository();
const agentIntegrationRepository = new AgentIntegrationRepository();
Expand Down Expand Up @@ -585,4 +586,98 @@ describe('Human interactions (create → deliver → resolve) #novu-v2', () => {
expect(listRes.body.data.length).to.be.greaterThan(0);
});
});

describe('keyless demo cap', () => {
let originalKeylessOrgId: string | undefined;
let originalCap: string | undefined;

beforeEach(() => {
originalKeylessOrgId = process.env.KEYLESS_ORGANIZATION_ID;
originalCap = process.env.KEYLESS_HUMAN_INTERACTION_CAP;
process.env.KEYLESS_ORGANIZATION_ID = session.organization._id;
process.env.KEYLESS_HUMAN_INTERACTION_CAP = '2';
});

afterEach(() => {
restoreEnv('KEYLESS_ORGANIZATION_ID', originalKeylessOrgId);
restoreEnv('KEYLESS_HUMAN_INTERACTION_CAP', originalCap);
});

function restoreEnv(name: string, value: string | undefined) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}

function telegramSends() {
return telegramApiStub.calls.filter((call) => call.method === 'sendMessage');
}

it('replaces delivery with the sign-up card once the cap is reached, and only sends it once', async () => {
expect((await createInteraction({ kind: 'tell', prompt: 'One.' })).status).to.equal(201);
expect((await createInteraction({ kind: 'tell', prompt: 'Two.' })).status).to.equal(201);
const sendsBefore = telegramSends().length;

const third = await createInteraction({ kind: 'ask', prompt: 'Three?' });
expect(third.status).to.equal(429, JSON.stringify(third.body));
expect(third.body.code).to.equal('KEYLESS_HUMAN_CAP_REACHED');
expect(third.body.cap).to.equal(2);
expect(third.body.claimUrl).to.match(/\/connect\/claim\?token=/);
expect(third.body.message).to.include(third.body.claimUrl);

// The human got the CTA card on the channel the prompt would have used — not the prompt itself.
const sends = telegramSends();
expect(sends.length).to.equal(sendsBefore + 1);
const cta = JSON.stringify(sends[sends.length - 1].payload);
expect(cta).to.include('connect/claim');
expect(cta).to.include('Sign up');
expect(cta).to.not.include('Three?');

// Nothing was persisted for the blocked call.
expect(await humanInteractionRepository.count({ _environmentId: session.environment._id })).to.equal(2);

// A retrying agent keeps getting the 429 + link, but the human is not spammed again.
const fourth = await createInteraction({ kind: 'ask', prompt: 'Four?' });
expect(fourth.status).to.equal(429);
expect(fourth.body.claimUrl).to.equal(third.body.claimUrl);
expect(telegramSends().length).to.equal(sendsBefore + 1);
});

it('does not apply to non-keyless organizations', async () => {
process.env.KEYLESS_ORGANIZATION_ID = 'some-other-org';

expect((await createInteraction({ kind: 'tell', prompt: 'One.' })).status).to.equal(201);
expect((await createInteraction({ kind: 'tell', prompt: 'Two.' })).status).to.equal(201);
expect((await createInteraction({ kind: 'tell', prompt: 'Three.' })).status).to.equal(201);
});

it('moves interactions with the claim and points a stale keyless credential at re-auth', async () => {
const created = await createInteraction({ kind: 'approve', prompt: 'Keep me?' });
expect(created.status).to.equal(201, JSON.stringify(created.body));

const tokenService = testServer.getService(ConnectClaimTokenService);
const { token } = await tokenService.issueOrGetForEnvironment({
env: session.environment._id,
org: session.organization._id,
});

const claimer = new UserSession();
await claimer.initialize();
const claimRes = await claimer.testAgent.post('/v1/connect/claim').send({ token });
expect(claimRes.status).to.equal(200, JSON.stringify(claimRes.body));
const targetEnvironmentId = claimRes.body.data.environmentId as string;

const row = await humanInteractionRepository.findByIdentifier(targetEnvironmentId, created.body.data.id);
expect(row?._organizationId).to.equal(claimer.organization._id);
expect(await humanInteractionRepository.count({ _environmentId: session.environment._id })).to.equal(0);

// The keyless env is now empty; the CLI must be told to re-auth, not to run setup again.
const after = await createInteraction({ kind: 'tell', prompt: 'Still here?' });
expect(after.status).to.equal(403, JSON.stringify(after.body));
expect(after.body.message).to.match(/claimed into your Novu account/);
expect(after.body.message).to.include('human setup --secret-key');
});
});
});
3 changes: 2 additions & 1 deletion apps/api/src/app/human/human.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '@novu/dal';
import { AgentsModule } from '../agents/agents.module';
import { AuthModule } from '../auth/auth.module';
import { ConnectModule } from '../connect/connect.module';
import { SharedModule } from '../shared/shared.module';
import { HumanInteractionsController } from './human-interactions.controller';
import { HumanDeliveryService } from './services/human-delivery.service';
Expand All @@ -24,7 +25,7 @@ import { SetupHumanRelay } from './usecases/setup-human-relay/setup-human-relay.
* Framework `ctx.*` helpers create in-thread cards via `CreateConversationInteraction`.
*/
@Module({
imports: [SharedModule, AuthModule, AgentsModule],
imports: [SharedModule, AuthModule, AgentsModule, ConnectModule],
controllers: [HumanInteractionsController],
providers: [
HumanInteractionRepository,
Expand Down
14 changes: 12 additions & 2 deletions apps/api/src/app/human/services/human-delivery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@novu/shared';
import { OutboundGateway } from '../../agents/conversation-runtime/egress/outbound.gateway';
import { buildPendingContent } from '../../agents/human-relay/human-card.builder';
import type { ReplyContentDto } from '../../agents/shared/dtos/agent-reply-payload.dto';

export interface ResolvedHumanTarget {
platform: string;
Expand Down Expand Up @@ -139,12 +140,21 @@ export class HumanDeliveryService {
async deliver(
interaction: HumanInteractionEntity,
target: ResolvedHumanTarget
): Promise<{ platformMessageId: string; platformThreadId: string }> {
return this.deliverContent(interaction._agentId, target, buildPendingContent(interaction));
}

/** One-off DM of arbitrary content (e.g. the keyless sign-up CTA) to an already-resolved target. */
async deliverContent(
agentId: string,
target: ResolvedHumanTarget,
content: ReplyContentDto
): Promise<{ platformMessageId: string; platformThreadId: string }> {
const sent = await this.outboundGateway.sendDirectMessage(
interaction._agentId,
agentId,
target.integrationIdentifier,
target.platformUserId,
buildPendingContent(interaction)
content
);

return { platformMessageId: sent.messageId, platformThreadId: sent.platformThreadId };
Expand Down
Loading
Loading