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
15 changes: 15 additions & 0 deletions .changeset/olive-donuts-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@getmunin/backend-core': minor
'@getmunin/inspector-app': minor
'@getmunin/dashboard-pages': patch
---

Outbound calls and text messages are approved only by a person in the dashboard

A proposal whose campaign runs on a voice or SMS channel can now only be approved by a signed-in dashboard user. `outreach_approve_proposal` refuses every other caller — an MCP agent, an unrestricted admin API key on the control plane, a curator, the Slack approval button — and the proposal stays `pending`. Email approval is unchanged, including by agents and admin keys.

The check lives in `OutreachService.approveProposal`, so it holds regardless of which surface the call arrives through. It is not an MCP tool-visibility hint: hosts that don't implement MCP Apps still list the tool, and calling it on a voice proposal fails there too.

Slack declines these in-thread with a pointer to the dashboard rather than surfacing a service error. The Inspector panel drops the approve button for voice and SMS proposals — Dismiss stays, since dismissing sends nothing — and says where the call is actually placed.

Quiet hours and blackout dates are now enforced when a call or text is approved. They were previously stored on the campaign and consulted only when listing due follow-ups, so nothing stopped a call at 3am. `cadenceRules` gains an optional `quietHoursTimezone` (IANA, validated) that quiet hours and blackout dates are read in; without it they are read in UTC, which is not what a Norwegian campaign means by "no calls before 08:00".
18 changes: 18 additions & 0 deletions packages/backend-core/docs-fixtures/mcp-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -6837,6 +6837,12 @@
"type": "string",
"pattern": "^[0-2]\\d:[0-5]\\d$"
},
"quietHoursTimezone": {
"description": "IANA time zone the quiet hours and blackout dates are read in, e.g. \"Europe/Oslo\". Defaults to UTC.",
"type": "string",
"minLength": 1,
"maxLength": 64
},
"blackoutDates": {
"maxItems": 50,
"type": "array",
Expand Down Expand Up @@ -6986,6 +6992,12 @@
"type": "string",
"pattern": "^[0-2]\\d:[0-5]\\d$"
},
"quietHoursTimezone": {
"description": "IANA time zone the quiet hours and blackout dates are read in, e.g. \"Europe/Oslo\". Defaults to UTC.",
"type": "string",
"minLength": 1,
"maxLength": 64
},
"blackoutDates": {
"maxItems": 50,
"type": "array",
Expand Down Expand Up @@ -7244,6 +7256,12 @@
"type": "string",
"pattern": "^[0-2]\\d:[0-5]\\d$"
},
"quietHoursTimezone": {
"description": "IANA time zone the quiet hours and blackout dates are read in, e.g. \"Europe/Oslo\". Defaults to UTC.",
"type": "string",
"minLength": 1,
"maxLength": 64
},
"blackoutDates": {
"maxItems": 50,
"type": "array",
Expand Down
4 changes: 2 additions & 2 deletions packages/backend-core/docs-fixtures/skills.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ImportBody = z.object({
maxPerWeekPerContact: z.number().int().positive().max(7).optional(),
quietHoursStart: z.string().optional(),
quietHoursEnd: z.string().optional(),
quietHoursTimezone: z.string().optional(),
blackoutDates: z.array(z.string()).optional(),
})
.default({}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,20 @@ const skipReason = TEST_URL
expect(listedProposals.find((p) => p.id === proposal.id)?.delivery?.destination).toBe(
'+14155559999',
);

const approved = await c.callTool({
name: 'outreach_approve_proposal',
arguments: { id: proposal.id },
});
expect(approved.isError).toBe(true);
expect(String(firstJson(approved))).toMatch(/signed-in person in the Munin dashboard/);

const after = await c.callTool({
name: 'outreach_list_proposals',
arguments: { campaignId: campaign.id },
});
const stillPending = firstJson(after) as Array<{ id: string; status: string }>;
expect(stillPending.find((p) => p.id === proposal.id)?.status).toBe('pending');
});
});

Expand Down
173 changes: 164 additions & 9 deletions packages/backend-core/src/modules/outreach/outreach.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,16 @@ const skipReason = TEST_URL
});

function run<T>(fn: () => Promise<T>): Promise<T> {
return runAs(actor, fn);
}

function runAs<T>(as: ActorIdentity, fn: () => Promise<T>): Promise<T> {
return appDb.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.bypass_rls', 'off', true)`);
await tx.execute(sql`SELECT set_config('app.org_id', ${orgId}, true)`);
const ctx: RequestContext = {
db: tx,
actor,
actor: as,
correlationId: randomUUID(),
};
return withContext(ctx, fn);
Expand Down Expand Up @@ -1138,13 +1142,27 @@ const skipReason = TEST_URL
globalThis.fetch = realFetch;
});

function runAsSystem<T>(fn: () => Promise<T>): Promise<T> {
function humanActor(): ActorIdentity {
return new ActorIdentity(
'user',
'usr_outreach_voice_test',
orgId,
['*'],
['admin'],
undefined,
undefined,
undefined,
'usr_outreach_voice_test',
);
}

function runAsSystem<T>(fn: () => Promise<T>, as: ActorIdentity = actor): Promise<T> {
return db.transaction(async (tx) => {
await tx.execute(sql`SELECT set_config('app.bypass_rls', 'on', true)`);
await tx.execute(
sql`SELECT set_config('app.crypt_key', ${process.env.MUNIN_ENCRYPTION_KEY ?? ''}, true)`,
);
const ctx: RequestContext = { db: tx, actor, correlationId: randomUUID() };
const ctx: RequestContext = { db: tx, actor: as, correlationId: randomUUID() };
return withContext(ctx, fn);
});
}
Expand Down Expand Up @@ -1311,8 +1329,9 @@ const skipReason = TEST_URL
draftBody: 'Quick follow-up call.',
}),
);
const approved = await runAsSystem(() =>
svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
const approved = await runAsSystem(
() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
humanActor(),
);
expect(approved.status).toBe('sent');
expect(approved.conversationId).toBeTruthy();
Expand Down Expand Up @@ -1370,8 +1389,9 @@ const skipReason = TEST_URL
draftBody: 'Hi.',
}),
);
const approved = await runAsSystem(() =>
svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
const approved = await runAsSystem(
() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
humanActor(),
);
expect(approved.conversationId).toBe(preexistingId);
const all = await db
Expand Down Expand Up @@ -1408,8 +1428,9 @@ const skipReason = TEST_URL
draftBody: 'Quick call.',
}),
);
const approved = await runAsSystem(() =>
svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
const approved = await runAsSystem(
() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
humanActor(),
);
await expect(
run(() =>
Expand All @@ -1420,5 +1441,139 @@ const skipReason = TEST_URL
),
).rejects.toBeInstanceOf(OutreachInvalidError);
});

it('an agent cannot approve a voice proposal, and no call is placed', async () => {
const { calls } = stubVapiPlaceCall();
const c = await run(() =>
svc.createCampaign({
name: 'voice-agent-refused',
brief: 'b',
segmentId,
channelId: voiceChannelId,
enabled: true,
}),
);
const p = await run(() =>
svc.proposeInitial({
campaignId: c.id,
contactId: voiceContactId,
draftBody: 'Quick call.',
}),
);
await expect(
runAsSystem(() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' })),
).rejects.toThrow(/signed-in person in the Munin dashboard/);
expect(calls.length).toBe(0);
const after = await run(() => svc.getProposal(p.id));
expect(after.status).toBe('pending');
});

it('a human cannot approve a voice proposal inside the campaign quiet hours', async () => {
const { calls } = stubVapiPlaceCall();
const now = new Date();
const quietStart = new Date(now.getTime() - 60 * 60 * 1000);
const quietEnd = new Date(now.getTime() + 60 * 60 * 1000);
const hhmm = (d: Date) =>
`${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`;
const c = await run(() =>
svc.createCampaign({
name: 'voice-quiet-hours',
brief: 'b',
segmentId,
channelId: voiceChannelId,
enabled: true,
cadenceRules: { quietHoursStart: hhmm(quietStart), quietHoursEnd: hhmm(quietEnd) },
}),
);
const p = await run(() =>
svc.proposeInitial({
campaignId: c.id,
contactId: voiceContactId,
draftBody: 'Quick call.',
}),
);
await expect(
runAsSystem(
() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
humanActor(),
),
).rejects.toThrow(/does not call between/);
expect(calls.length).toBe(0);
});

it('a human cannot approve a voice proposal on a blackout date', async () => {
const { calls } = stubVapiPlaceCall();
const c = await run(() =>
svc.createCampaign({
name: 'voice-blackout',
brief: 'b',
segmentId,
channelId: voiceChannelId,
enabled: true,
cadenceRules: { blackoutDates: [new Date().toISOString().slice(0, 10)] },
}),
);
const p = await run(() =>
svc.proposeInitial({
campaignId: c.id,
contactId: voiceContactId,
draftBody: 'Quick call.',
}),
);
await expect(
runAsSystem(
() => svc.approveProposal(p.id, { publicBaseUrl: 'http://localhost:3001' }),
humanActor(),
),
).rejects.toThrow(/does not call on /);
expect(calls.length).toBe(0);
});

it('an agent can still dismiss a voice proposal, since dismissing sends nothing', async () => {
const c = await run(() =>
svc.createCampaign({
name: 'voice-dismissable',
brief: 'b',
segmentId,
channelId: voiceChannelId,
enabled: true,
}),
);
const p = await run(() =>
svc.proposeInitial({
campaignId: c.id,
contactId: voiceContactId,
draftBody: 'Quick call.',
}),
);
const dismissed = await run(() =>
svc.dismissProposal({ id: p.id, reason: 'wrong list' }),
);
expect(dismissed.status).toBe('dismissed');
});

it('email approval is unaffected by the calling gate', async () => {
const c = await run(() =>
svc.createCampaign({
name: 'email-still-agent-approvable',
brief: 'b',
segmentId,
channelId,
enabled: true,
}),
);
const p = await run(() =>
svc.proposeInitial({
campaignId: c.id,
contactId,
draftSubject: 'Hello',
draftBody: 'Body.',
}),
);
const approved = await run(() =>
svc.approveProposal(p.id, { publicBaseUrl: 'https://test.local' }),
);
expect(approved.status).toBe('sent');
});
});
});
Loading
Loading