@sendinel/mcp-server 1.0.163 → 1.0.165
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/build/lib/action-approvals.d.ts +9 -2
- package/build/lib/action-approvals.js +58 -12
- package/build/lib/approval-classes.d.ts +17 -0
- package/build/lib/approval-classes.js +107 -0
- package/build/lib/approval-elicitation.d.ts +1 -0
- package/build/lib/approval-elicitation.js +2 -0
- package/build/lib/approval-human-callback.js +5 -1
- package/build/lib/approval-human-eligibility.d.ts +2 -1
- package/build/lib/approval-human-eligibility.js +2 -2
- package/build/lib/approval-snapshot.d.ts +10 -1
- package/build/lib/approval-snapshot.js +40 -3
- package/build/lib/approval-tool-decisions.js +4 -0
- package/build/lib/campaign-trigger-approval.js +8 -4
- package/build/lib/db-error.js +1 -0
- package/build/server.js +6 -1
- package/build/tools/approvals.js +12 -0
- package/build/tools/campaign-sets.js +36 -31
- package/build/tools/campaigns.js +33 -29
- package/build/tools/connector.js +1 -1
- package/build/tools/contacts.js +2 -2
- package/build/tools/data.js +2 -2
- package/build/tools/delivery-ops.js +1 -1
- package/build/tools/gdpr.js +18 -16
- package/build/tools/lists.js +3 -3
- package/build/tools/org.js +11 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 349 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -27,7 +27,7 @@ Sendinel currently exposes 35 tool groups and 348 tools (gateway mode exposes 4
|
|
|
27
27
|
| `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
|
|
28
28
|
| `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
|
|
29
29
|
| `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
|
|
30
|
-
| `approvals` |
|
|
30
|
+
| `approvals` | 6 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
|
|
31
31
|
| `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
|
|
32
32
|
| `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
|
|
33
33
|
| `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
|
|
@@ -31,7 +31,7 @@ export interface ExecutionDecision {
|
|
|
31
31
|
* from getApprovalTier(), which only decides the tier itself. Call this before
|
|
32
32
|
* requestApproval() at every gated/protected call site.
|
|
33
33
|
*/
|
|
34
|
-
export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): ExecutionDecision
|
|
34
|
+
export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): Promise<ExecutionDecision>;
|
|
35
35
|
/** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
|
|
36
36
|
export declare function currentChannelContext(): ChannelExecutionContext;
|
|
37
37
|
/**
|
|
@@ -71,7 +71,8 @@ type AdminApprovalAuthority = {
|
|
|
71
71
|
id?: string;
|
|
72
72
|
action: string;
|
|
73
73
|
projectId: string;
|
|
74
|
-
phase: 'preview' | 'approved';
|
|
74
|
+
phase: 'preview' | 'approved' | 'level';
|
|
75
|
+
levelUserId?: string;
|
|
75
76
|
decidedBy?: string;
|
|
76
77
|
decisionChannel?: 'sandbox';
|
|
77
78
|
};
|
|
@@ -105,6 +106,12 @@ type ToolResult = {
|
|
|
105
106
|
text: string;
|
|
106
107
|
}>;
|
|
107
108
|
};
|
|
109
|
+
export declare function approvalSatisfied(action: string): boolean;
|
|
110
|
+
/** Catch preview/draft branches that historically returned before their hand-written gate.
|
|
111
|
+
* Existing gated execution paths retain their frozen-payload approval flow. */
|
|
112
|
+
export declare function withPreviewApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R): (args: A & {
|
|
113
|
+
approval_id?: string;
|
|
114
|
+
}, extra?: unknown) => Promise<R | ToolResult>;
|
|
108
115
|
export declare function markNotExecuted<R extends object>(result: R): R;
|
|
109
116
|
/** HTTP statuses that mean the request was refused before it changed anything. */
|
|
110
117
|
export declare function isPreExecutionStatus(status: number | undefined): boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { effectiveApproval, auditApprovalLevel } from './approval-classes.js';
|
|
1
2
|
import { sandboxApprovalEligible, trySandboxApproval } from './sandbox-approvals.js';
|
|
2
3
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
3
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
@@ -92,8 +93,16 @@ const TIER2_CHANNELS = new Set([
|
|
|
92
93
|
* from getApprovalTier(), which only decides the tier itself. Call this before
|
|
93
94
|
* requestApproval() at every gated/protected call site.
|
|
94
95
|
*/
|
|
95
|
-
export function assertChannelCanExecute(action, params, context) {
|
|
96
|
-
|
|
96
|
+
export async function assertChannelCanExecute(action, params, context) {
|
|
97
|
+
if (approvalSatisfied(action))
|
|
98
|
+
return { allowed: true, tier: 'autonomous' };
|
|
99
|
+
const effective = await effectiveApproval(action, params);
|
|
100
|
+
const originalTier = getApprovalTier(action, params);
|
|
101
|
+
const tier = effective.prompt ? (originalTier === 'autonomous' ? 'gated' : originalTier) : 'autonomous';
|
|
102
|
+
// An unverified caller never gains an exemption through a level.
|
|
103
|
+
if (effective.applied && !context.verified)
|
|
104
|
+
return { allowed: false, tier, reason: 'Verified channel required.' };
|
|
105
|
+
await auditApprovalLevel(action, effective);
|
|
97
106
|
if (tier === 'autonomous')
|
|
98
107
|
return { allowed: true, tier };
|
|
99
108
|
if (!context.verified) {
|
|
@@ -267,20 +276,26 @@ function approvalParamsEqual(expected, actual) {
|
|
|
267
276
|
* and returns instructions for the agent to wait.
|
|
268
277
|
*/
|
|
269
278
|
export async function requestApproval(action, params, apiKeyId, channelContext, display) {
|
|
270
|
-
const
|
|
279
|
+
const effective = await effectiveApproval(action, params);
|
|
280
|
+
const originalTier = getApprovalTier(action, params);
|
|
281
|
+
const tier = originalTier === 'autonomous' ? 'gated' : originalTier;
|
|
271
282
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
|
|
272
283
|
const { channel, verified } = channelContext ?? currentChannelContext();
|
|
273
284
|
const actor = requestContext();
|
|
274
285
|
// Ignore any model-supplied reserved field; freeze facts read by the server.
|
|
275
286
|
const cleanParams = withoutApprovalSnapshot(params);
|
|
276
|
-
const snapshot = await captureSnapshot(action, cleanParams)
|
|
287
|
+
const snapshot = { ...await captureSnapshot(action, cleanParams), ...(effective.applied ? { approval_class: effective.class, approval_channel_rule: effective.channelRule } : {}),
|
|
288
|
+
...(display && 'recipient_count' in display ? { display_recipient_count: display.recipient_count } : {}) };
|
|
277
289
|
const payload = cleanParams.payload;
|
|
278
290
|
if (action === 'send_broadcast' && Array.isArray(payload?.contact_ids) && Array.isArray(snapshot.contact_ids) &&
|
|
279
291
|
!approvalParamsEqual({ ids: [...payload.contact_ids].sort() }, { ids: snapshot.contact_ids })) {
|
|
280
292
|
throw new Error('Approval audience changed while preparing the preview. Request a new approval.');
|
|
281
293
|
}
|
|
282
294
|
const frozenParams = { ...cleanParams, __approval_snapshot: snapshot };
|
|
283
|
-
const
|
|
295
|
+
const detailSummary = buildApprovalSummary(action, frozenParams, display);
|
|
296
|
+
const preview = buildApprovalPreview({ action, params: frozenParams, summary: detailSummary,
|
|
297
|
+
...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) });
|
|
298
|
+
const summary = `${preview.plain_summary.split('\n')[0]}\n${detailSummary}`;
|
|
284
299
|
const { data, error } = await approvalStore()
|
|
285
300
|
.from('action_approvals')
|
|
286
301
|
.insert({
|
|
@@ -303,7 +318,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
303
318
|
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
304
319
|
let projectName;
|
|
305
320
|
let sandboxDisplayRead = false;
|
|
306
|
-
const sandboxApproved = tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
|
|
321
|
+
const sandboxApproved = !(effective.applied && (effective.level === 'ask_every_time' || effective.class === 'F')) && tier === 'gated' && await trySandboxApproval(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; });
|
|
307
322
|
if (isTesterRequest(action))
|
|
308
323
|
await logTesterRequestAudit('tester_request', action, data.id, null);
|
|
309
324
|
fireWebhookEvent('approval.requested', {
|
|
@@ -337,7 +352,7 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
337
352
|
: '';
|
|
338
353
|
const result = await elicitApproval({
|
|
339
354
|
requires_approval: true,
|
|
340
|
-
...(await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
355
|
+
...(effective.channelRule === 'human_only' ? {} : await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
341
356
|
payload_hash: approvalPayloadHash(action, frozenParams) })),
|
|
342
357
|
approval_id: data.id,
|
|
343
358
|
tier,
|
|
@@ -347,8 +362,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
347
362
|
approve_via: 'Open approve_url (works on a phone) or Dashboard → Settings → Pending Approvals',
|
|
348
363
|
approve_url: approveUrl,
|
|
349
364
|
preview_text: previewText,
|
|
350
|
-
message:
|
|
351
|
-
}, { action, params: frozenParams, summary, projectName }, getProjectId());
|
|
365
|
+
message: `${previewText}\nTap to approve: ${approveUrl}\nIt stays open for 24 hours. ${effective.channelRule === 'human_only' ? 'Use the dashboard link or a typed chat reply.' : 'In a terminal with prompts you can also answer the on-screen Accept/Decline.'}${chatGuidance}`,
|
|
366
|
+
}, { action, params: frozenParams, summary, projectName, channelRule: effective.channelRule }, getProjectId());
|
|
352
367
|
if (result.decision_status === 'approved') {
|
|
353
368
|
return { ...result, message: 'Approved. Call this tool again with the same arguments plus approval_id to run it.' };
|
|
354
369
|
}
|
|
@@ -444,6 +459,9 @@ async function checkApprovalState(approvalId, options = {}) {
|
|
|
444
459
|
!await sandboxApprovalEligible(getProjectId(), data.action))) {
|
|
445
460
|
return { approved: false, status: 'sandbox_decision_no_longer_eligible' };
|
|
446
461
|
}
|
|
462
|
+
if (data.status === 'approved' && data.params?.__approval_snapshot?.approval_channel_rule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
|
|
463
|
+
return { approved: false, status: 'human_decision_required' };
|
|
464
|
+
}
|
|
447
465
|
if (data.status === 'approved' && !sandboxDecision && (!isHumanApprovalChannel(data.decided_via) ||
|
|
448
466
|
(isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
|
|
449
467
|
(!['mcp_app', 'mcp_elicitation', 'chat_confirm', 'mcp_person'].includes(data.decided_via) ||
|
|
@@ -494,6 +512,9 @@ async function checkApprovalState(approvalId, options = {}) {
|
|
|
494
512
|
if (claimError || !claimed) {
|
|
495
513
|
return { approved: false, status: 'consumed' };
|
|
496
514
|
}
|
|
515
|
+
if ((await effectiveApproval(data.action, withoutApprovalSnapshot(data.params))).channelRule === 'human_only' && !['dashboard', 'chat_confirm'].includes(data.decided_via)) {
|
|
516
|
+
return { approved: false, status: 'human_decision_required' };
|
|
517
|
+
}
|
|
497
518
|
return {
|
|
498
519
|
approved: true,
|
|
499
520
|
status: 'approved',
|
|
@@ -505,6 +526,30 @@ async function checkApprovalState(approvalId, options = {}) {
|
|
|
505
526
|
function toolText(value) {
|
|
506
527
|
return { content: [{ type: 'text', text: value }] };
|
|
507
528
|
}
|
|
529
|
+
const satisfiedApproval = new AsyncLocalStorage();
|
|
530
|
+
export function approvalSatisfied(action) {
|
|
531
|
+
const authority = registeredApproval.getStore();
|
|
532
|
+
return satisfiedApproval.getStore() === action || (authority?.phase === 'level' && authority.action === action && authority.projectId === getProjectId());
|
|
533
|
+
}
|
|
534
|
+
/** Catch preview/draft branches that historically returned before their hand-written gate.
|
|
535
|
+
* Existing gated execution paths retain their frozen-payload approval flow. */
|
|
536
|
+
export function withPreviewApproval(action, handler) {
|
|
537
|
+
return async (args, extra) => {
|
|
538
|
+
const params = args;
|
|
539
|
+
const previewBranch = (['launch_campaign', 'create_campaign_with_content'].includes(action) && params.confirmed !== true) ||
|
|
540
|
+
['update_campaign', 'set_campaign_trigger'].includes(action);
|
|
541
|
+
if (!previewBranch && getApprovalTier(action, params) !== 'autonomous')
|
|
542
|
+
return handler(args, extra);
|
|
543
|
+
const decision = await effectiveApproval(action, args);
|
|
544
|
+
if (!decision.applied || (previewBranch && !['launch_campaign', 'create_campaign_with_content'].includes(action) && decision.level !== 'ask_every_time'))
|
|
545
|
+
return handler(args, extra);
|
|
546
|
+
if (!decision.prompt) {
|
|
547
|
+
await auditApprovalLevel(action, decision);
|
|
548
|
+
return satisfiedApproval.run(action, () => handler(args, extra));
|
|
549
|
+
}
|
|
550
|
+
return withApproval(action, (params, innerExtra) => satisfiedApproval.run(action, () => handler(params, innerExtra)))(args, extra);
|
|
551
|
+
};
|
|
552
|
+
}
|
|
508
553
|
/**
|
|
509
554
|
* Wrap a tool handler in the standard approval flow (#4005). In standard mode the first call
|
|
510
555
|
* (no approval_id) records the exact arguments and returns the approval request without running
|
|
@@ -530,13 +575,13 @@ export function withApproval(action, handler, options = {}) {
|
|
|
530
575
|
return async (args, extra) => {
|
|
531
576
|
const { approval_id, ...params } = args;
|
|
532
577
|
const channelContext = currentChannelContext();
|
|
533
|
-
const decision = assertChannelCanExecute(action, params, channelContext);
|
|
578
|
+
const decision = await assertChannelCanExecute(action, params, channelContext);
|
|
534
579
|
if (!decision.allowed)
|
|
535
580
|
return toolText(decision.reason ?? 'This channel cannot request this action.');
|
|
536
581
|
let decidedBy;
|
|
537
582
|
let decisionChannel;
|
|
538
583
|
let consumedAt;
|
|
539
|
-
if (
|
|
584
|
+
if (decision.tier !== 'autonomous') {
|
|
540
585
|
if (!approval_id) {
|
|
541
586
|
const display = options.display ? await options.display(params) : undefined;
|
|
542
587
|
return toolText(JSON.stringify(await requestApproval(action, params, undefined, channelContext, display), null, 2));
|
|
@@ -550,7 +595,8 @@ export function withApproval(action, handler, options = {}) {
|
|
|
550
595
|
}
|
|
551
596
|
}
|
|
552
597
|
if (options.registeredRequestOnly) {
|
|
553
|
-
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: '
|
|
598
|
+
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: decision.tier === 'autonomous' ? 'level' : 'approved',
|
|
599
|
+
...(decision.tier === 'autonomous' ? { levelUserId: requestContext()?.actorId ?? undefined } : {}), decidedBy, decisionChannel }, () => handler(args, extra));
|
|
554
600
|
}
|
|
555
601
|
const result = await handler(params, extra);
|
|
556
602
|
// Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ApprovalClass, type ApprovalLevel } from '@sendinel/domain';
|
|
2
|
+
import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
|
|
3
|
+
export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
|
|
4
|
+
export type { ApprovalClass, ApprovalLevel } from '@sendinel/domain';
|
|
5
|
+
export type EffectiveApproval = {
|
|
6
|
+
prompt: boolean;
|
|
7
|
+
channelRule: 'any' | 'human_only';
|
|
8
|
+
class: ApprovalClass | undefined;
|
|
9
|
+
level: ApprovalLevel;
|
|
10
|
+
applied: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** No transport-supplied role or preference grants authority: verify the live person grant. */
|
|
13
|
+
export declare function personApprovalLevel(ctx?: HumanDecisionIdentity | undefined, action?: string): Promise<ApprovalLevel | null>;
|
|
14
|
+
/** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
|
|
15
|
+
export declare function effectiveApproval(action: string, params?: Record<string, unknown>, ctx?: HumanDecisionIdentity | undefined): Promise<EffectiveApproval>;
|
|
16
|
+
/** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
|
|
17
|
+
export declare function auditApprovalLevel(action: string, decision: EffectiveApproval): Promise<void>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { approvalClass, isApprovalLevel, matchSendAllowlist, readContactTestStatus } from '@sendinel/domain';
|
|
2
|
+
import { adminDb, requestContext } from '../db.js';
|
|
3
|
+
import { getProjectId } from '../project.js';
|
|
4
|
+
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
5
|
+
import { getApprovalTier } from './action-approvals.js';
|
|
6
|
+
export { APPROVAL_CLASSES, approvalClass } from '@sendinel/domain';
|
|
7
|
+
/** No transport-supplied role or preference grants authority: verify the live person grant. */
|
|
8
|
+
export async function personApprovalLevel(ctx = requestContext(), action = 'get_approval_level') {
|
|
9
|
+
if (ctx?.scopes?.some(scope => scope === 'service' || scope.startsWith('service:')))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
if (!await humanApprovalEligible(action, getProjectId(), ctx, false, () => adminDb().schema('email')))
|
|
13
|
+
return null;
|
|
14
|
+
const { data, error } = await adminDb().schema('email').from('approval_preferences')
|
|
15
|
+
.select('level').eq('user_id', ctx.actorId).maybeSingle();
|
|
16
|
+
if (error || (data && !isApprovalLevel(data.level)))
|
|
17
|
+
return null;
|
|
18
|
+
return data?.level ?? 'default';
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Explicit mailbox allowlists only: a NULL policy means unrestricted, never test-safe. */
|
|
25
|
+
async function safeAddresses(addresses, ctx) {
|
|
26
|
+
if (!addresses.length || !ctx.actorId)
|
|
27
|
+
return false;
|
|
28
|
+
const client = adminDb();
|
|
29
|
+
const { data, error } = await client.auth.admin.getUserById(ctx.actorId);
|
|
30
|
+
if (error || !data?.user?.email)
|
|
31
|
+
return false;
|
|
32
|
+
const remaining = addresses.filter(address => address.trim().toLowerCase() !== data.user.email.toLowerCase());
|
|
33
|
+
if (!remaining.length)
|
|
34
|
+
return true;
|
|
35
|
+
const project = await client.schema('email').from('projects')
|
|
36
|
+
.select('send_allowlist, org_id, org:organizations(send_allowlist)').eq('id', getProjectId()).single();
|
|
37
|
+
if (project.error || !project.data)
|
|
38
|
+
return false;
|
|
39
|
+
const lists = [project.data.send_allowlist, project.data.org?.send_allowlist].filter(Array.isArray);
|
|
40
|
+
return remaining.every(address => lists.some(list => !matchSendAllowlist(list, { to: address })));
|
|
41
|
+
}
|
|
42
|
+
async function exemptTargets(action, params, ctx) {
|
|
43
|
+
try {
|
|
44
|
+
if (action === 'draft_agent_reply')
|
|
45
|
+
return true;
|
|
46
|
+
if (action === 'send_test_email') {
|
|
47
|
+
// Include every copied recipient defensively; never infer recipients from display text.
|
|
48
|
+
const copied = [params.cc, params.bcc].filter(value => value !== undefined);
|
|
49
|
+
if (copied.some(value => !Array.isArray(value) || value.some(address => typeof address !== 'string')))
|
|
50
|
+
return false;
|
|
51
|
+
return typeof params.to_email === 'string' && await safeAddresses([params.to_email, ...copied.flat()], ctx);
|
|
52
|
+
}
|
|
53
|
+
// Filters and segments are open audiences, never an exemption based on a sample.
|
|
54
|
+
if (['enroll_segment', 'add_list_members_by_filter'].includes(action))
|
|
55
|
+
return false;
|
|
56
|
+
const ids = Array.isArray(params.contact_ids) ? params.contact_ids : params.contact_id ? [params.contact_id] : [];
|
|
57
|
+
if (ids.length) {
|
|
58
|
+
if (ids.some(id => typeof id !== 'string'))
|
|
59
|
+
return false;
|
|
60
|
+
const contacts = await readContactTestStatus(adminDb(), getProjectId(), ids);
|
|
61
|
+
return (await Promise.all(contacts.map(contact => contact.is_test === true ||
|
|
62
|
+
(typeof contact.email === 'string' && safeAddresses([contact.email], ctx))))).every(Boolean);
|
|
63
|
+
}
|
|
64
|
+
// Never trust a model-supplied is_test flag. Read the saved record by its address.
|
|
65
|
+
const address = params.email;
|
|
66
|
+
if (typeof address !== 'string')
|
|
67
|
+
return false;
|
|
68
|
+
if (await safeAddresses([address], ctx))
|
|
69
|
+
return true;
|
|
70
|
+
const { data, error } = await adminDb().schema('email').from('subscribers')
|
|
71
|
+
.select('is_test').eq('project_id', getProjectId()).eq('email', address.trim().toLowerCase()).maybeSingle();
|
|
72
|
+
return !error && data?.is_test === true;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** One decision function. Lookup errors preserve today's tier, including preview exceptions. */
|
|
79
|
+
export async function effectiveApproval(action, params = {}, ctx = requestContext()) {
|
|
80
|
+
const baseline = getApprovalTier(action, params) !== 'autonomous';
|
|
81
|
+
let kind = approvalClass(action);
|
|
82
|
+
const level = kind ? await personApprovalLevel(ctx, action) : null;
|
|
83
|
+
if (!level)
|
|
84
|
+
return { prompt: baseline, channelRule: 'any', class: kind, level: 'default', applied: false };
|
|
85
|
+
const exempt = (kind === 'T' || kind === 'E') && await exemptTargets(action, params, ctx);
|
|
86
|
+
if (action === 'send_test_email' && !exempt)
|
|
87
|
+
kind = 'S';
|
|
88
|
+
// Live trigger edits have an additional domain gate even under the legacy autonomous mode.
|
|
89
|
+
const triggerGate = ['update_campaign', 'set_campaign_trigger'].includes(action) && !!params.previous_campaign && !!params.updates;
|
|
90
|
+
const prompt = level === 'ask_every_time' ? true : level === 'auto' ? (kind === 'F' || kind === 'D') : (baseline || triggerGate) && !exempt;
|
|
91
|
+
return { prompt, class: kind, level, applied: true,
|
|
92
|
+
channelRule: level === 'ask_every_time' && (kind === 'S' || kind === 'F' || (kind === 'E' && !exempt)) ? 'human_only' : 'any' };
|
|
93
|
+
}
|
|
94
|
+
/** Audit must succeed before a level can authorize execution. Never turn an audit failure into a send. */
|
|
95
|
+
export async function auditApprovalLevel(action, decision) {
|
|
96
|
+
if (decision.prompt || !decision.applied || (decision.level === 'default' && decision.class !== 'T' && decision.class !== 'E'))
|
|
97
|
+
return;
|
|
98
|
+
const ctx = requestContext();
|
|
99
|
+
const { error } = await adminDb().schema('email').from('audit_log').insert({
|
|
100
|
+
project_id: getProjectId(), actor_type: 'user', actor_id: ctx.actorId,
|
|
101
|
+
action: 'approval.level_applied', resource_type: 'approval',
|
|
102
|
+
details: { action, class: decision.class, level: decision.level, user_id: ctx.actorId,
|
|
103
|
+
connection_id: `oauth:${ctx.oauthTokenId}`, reason: 'level' },
|
|
104
|
+
});
|
|
105
|
+
if (error)
|
|
106
|
+
throw new Error('Could not record your approval level. Nothing was run.');
|
|
107
|
+
}
|
|
@@ -57,6 +57,7 @@ export declare function elicitApproval<T extends ApprovalFallback>(result: T, fr
|
|
|
57
57
|
params: unknown;
|
|
58
58
|
summary: string;
|
|
59
59
|
projectName?: string;
|
|
60
|
+
channelRule?: 'any' | 'human_only';
|
|
60
61
|
}, projectId: string): Promise<T & {
|
|
61
62
|
decision_status: ElicitationDecision;
|
|
62
63
|
}>;
|
|
@@ -29,6 +29,8 @@ export async function elicitApproval(result, frozen, projectId) {
|
|
|
29
29
|
const pending = { ...result, decision_status: 'decision_pending' };
|
|
30
30
|
const warn = (reason) => console.warn('[approval_wait]', { stage: 'elicitation', reason });
|
|
31
31
|
const exit = (reason) => { warn(reason); return pending; };
|
|
32
|
+
if (frozen.channelRule === 'human_only')
|
|
33
|
+
return exit('human_only');
|
|
32
34
|
const context = requests.getStore();
|
|
33
35
|
if (!context)
|
|
34
36
|
return exit('no_transport_context');
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { effectiveApproval } from './approval-classes.js';
|
|
1
2
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
2
3
|
import { requestContext } from '../db.js';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -21,11 +22,14 @@ export function createHumanApprovalCallback(binding) {
|
|
|
21
22
|
const source = approvalStore();
|
|
22
23
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
23
24
|
const { data: approval, error: loadError } = await client.from('action_approvals')
|
|
24
|
-
.select('action').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
|
|
25
|
+
.select('action, params').eq('id', binding.approvalId).eq('project_id', binding.projectId).maybeSingle();
|
|
25
26
|
if (loadError || typeof approval?.action !== 'string' || !approval.action ||
|
|
26
27
|
requestContext()?.actorId !== binding.userId ||
|
|
27
28
|
!await humanApprovalEligible(approval.action, binding.projectId))
|
|
28
29
|
return false;
|
|
30
|
+
if (approval.params?.__approval_snapshot?.approval_channel_rule === 'human_only' ||
|
|
31
|
+
(await effectiveApproval(approval.action, approval.params)).channelRule === 'human_only')
|
|
32
|
+
return false;
|
|
29
33
|
const now = new Date().toISOString();
|
|
30
34
|
const { data, error } = await client.from('action_approvals')
|
|
31
35
|
.update({ status: response.decision === 'approve' ? 'approved' : 'rejected', decided_via: binding.channel,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { approvalStore } from './tester-requests-store.js';
|
|
1
2
|
export type HumanDecisionIdentity = {
|
|
2
3
|
projectId?: string;
|
|
3
4
|
actorId?: string | null;
|
|
@@ -11,7 +12,7 @@ export type HumanDecisionIdentity = {
|
|
|
11
12
|
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
12
13
|
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
13
14
|
*/
|
|
14
|
-
export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean): Promise<boolean>;
|
|
15
|
+
export declare function humanApprovalEligible(action: string, projectId: string, identity?: HumanDecisionIdentity | undefined, requireChatConfirm?: boolean, store?: () => ReturnType<typeof approvalStore>): Promise<boolean>;
|
|
15
16
|
/** Opt-in is a live grant fact as well as a transport snapshot; errors never enable it. */
|
|
16
17
|
export declare function chatConfirmConnectionEnabled(identity?: {
|
|
17
18
|
scopes?: readonly string[];
|
|
@@ -5,14 +5,14 @@ import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
|
5
5
|
* (for admin requests) the project's org membership on every prompt and every decision.
|
|
6
6
|
* Missing connection_role is NOT a human grant: it must be exactly SQL NULL.
|
|
7
7
|
*/
|
|
8
|
-
export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false) {
|
|
8
|
+
export async function humanApprovalEligible(action, projectId, identity = requestContext(), requireChatConfirm = false, store) {
|
|
9
9
|
if (!identity || identity.channelVerified !== true || identity.authKind !== 'oauth' ||
|
|
10
10
|
(requireChatConfirm && identity.chatConfirmEnabled !== true) ||
|
|
11
11
|
!identity.actorId || !identity.oauthTokenId || identity.projectId !== projectId || !projectId ||
|
|
12
12
|
identity.scopes?.some(scope => scope === 'tester' || scope === 'agent'))
|
|
13
13
|
return false;
|
|
14
14
|
try {
|
|
15
|
-
const source = approvalStore();
|
|
15
|
+
const source = store ? store() : approvalStore();
|
|
16
16
|
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
17
17
|
const { data: grant, error } = await client.from('mcp_oauth_tokens')
|
|
18
18
|
.select('id, user_id, connection_role, scopes, revoked_at, expires_at, chat_confirm_enabled')
|
|
@@ -23,6 +23,15 @@ export declare function captureApprovalDisplay(action: string, params: Params, p
|
|
|
23
23
|
/** Exclude only optional display facts from revalidation, never from the payload hash. */
|
|
24
24
|
export declare function withoutApprovalDisplay(snapshot: Params): Params;
|
|
25
25
|
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
26
|
-
export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<
|
|
26
|
+
export declare function captureApprovalSnapshot(action: string, params: Params, projectId: string): Promise<{
|
|
27
|
+
version: number;
|
|
28
|
+
invitation: Params;
|
|
29
|
+
recipient_count: number;
|
|
30
|
+
} | {
|
|
31
|
+
display?: {
|
|
32
|
+
target_name: string;
|
|
33
|
+
target_detail?: string | undefined;
|
|
34
|
+
} | undefined;
|
|
35
|
+
}>;
|
|
27
36
|
export declare function withoutApprovalSnapshot(params: unknown): Record<string, unknown>;
|
|
28
37
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isTesterRequest } from './tester-policy.js';
|
|
2
2
|
import { captureTesterRequestSnapshot } from './tester-request-snapshot.js';
|
|
3
|
-
import { readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
|
|
3
|
+
import { campaignMatchesTrigger, normalizeTriggerList, readAllRows, chunkIds, scopeSegmentRulesToSite, approvalDisplayFact, parseLinkedInCommentUrl } from '@sendinel/domain';
|
|
4
4
|
import { callSendinelV1 } from './v1-client.js';
|
|
5
5
|
import { adminDb, db } from '../db.js';
|
|
6
6
|
import { resolveSendAudience, resolveSegmentContactIds } from './send-audience.js';
|
|
@@ -155,9 +155,45 @@ export async function captureApprovalDisplay(action, params, projectId) {
|
|
|
155
155
|
}
|
|
156
156
|
/** Exclude only optional display facts from revalidation, never from the payload hash. */
|
|
157
157
|
export function withoutApprovalDisplay(snapshot) {
|
|
158
|
-
const { display: _display, ...rest } = snapshot;
|
|
158
|
+
const { display: _display, approval_class: _class, approval_channel_rule: _rule, display_recipient_count: _count, ...rest } = snapshot;
|
|
159
159
|
return rest;
|
|
160
160
|
}
|
|
161
|
+
/** Potential sending campaigns, using the intake matchers. Unknown is never called no-send. */
|
|
162
|
+
async function captureEnrollmentConsequence(action, params, projectId) {
|
|
163
|
+
if (!['identify_subscriber', 'emit_contact_event', 'enroll_contact', 'enroll_segment'].includes(action))
|
|
164
|
+
return {};
|
|
165
|
+
const source = db();
|
|
166
|
+
const client = typeof source.schema === 'function' ? source.schema('email') : source;
|
|
167
|
+
if (action === 'enroll_contact' || action === 'enroll_segment') {
|
|
168
|
+
const { data, error } = await client.from('campaigns').select('id, name').eq('project_id', projectId).eq('id', params.campaign_id).maybeSingle();
|
|
169
|
+
if (error || !data)
|
|
170
|
+
throw new Error('Approval enrollment campaign unavailable');
|
|
171
|
+
let contact_email;
|
|
172
|
+
if (params.contact_id) {
|
|
173
|
+
const contact = await client.from('subscribers').select('email').eq('id', params.contact_id).eq('project_id', projectId).maybeSingle();
|
|
174
|
+
if (contact.error || !contact.data)
|
|
175
|
+
throw new Error('Approval contact unavailable');
|
|
176
|
+
contact_email = contact.data.email;
|
|
177
|
+
}
|
|
178
|
+
return { enrollment_campaigns: [data], ...(contact_email ? { contact_email } : {}) };
|
|
179
|
+
}
|
|
180
|
+
const campaigns = await readAllRows(client.from('campaigns')
|
|
181
|
+
.select('id, name, site_id, type, trigger_event_name, trigger_tag, trigger_config')
|
|
182
|
+
.eq('project_id', projectId).eq('status', 'active'));
|
|
183
|
+
let tags = Array.isArray(params.tags) ? params.tags : [];
|
|
184
|
+
if (action === 'emit_contact_event') {
|
|
185
|
+
const contact = await client.from('subscribers').select('tags').eq('project_id', projectId)
|
|
186
|
+
.eq('email', params.email).maybeSingle();
|
|
187
|
+
if (contact.error)
|
|
188
|
+
throw new Error('Approval event contact unavailable');
|
|
189
|
+
tags = normalizeTriggerList(contact.data?.tags ?? []);
|
|
190
|
+
}
|
|
191
|
+
const matches = campaigns.filter(campaign => action === 'identify_subscriber'
|
|
192
|
+
? ((params.site_id && params.consent !== 'customer' && campaign.site_id === params.site_id && campaign.trigger_event_name === 'signup') ||
|
|
193
|
+
(campaign.type === 'triggered' && tags.some((tag) => campaignMatchesTrigger(campaign, { tags: [tag] }))))
|
|
194
|
+
: campaign.type === 'triggered' && campaignMatchesTrigger(campaign, { event: params.event, tags, data: params.data }));
|
|
195
|
+
return { enrollment_campaigns: matches.map(campaign => ({ id: campaign.id, name: campaign.name })).sort((a, b) => String(a.id).localeCompare(String(b.id))) };
|
|
196
|
+
}
|
|
161
197
|
/** Server-owned display facts, saved inside the hash-bound params. Never supplied by a tool. */
|
|
162
198
|
export async function captureApprovalSnapshot(action, params, projectId) {
|
|
163
199
|
if (action === 'invite_team_member') {
|
|
@@ -169,7 +205,8 @@ export async function captureApprovalSnapshot(action, params, projectId) {
|
|
|
169
205
|
return { version: 1, invitation: preview, recipient_count: 1 };
|
|
170
206
|
}
|
|
171
207
|
const display = await captureApprovalDisplay(action, params, projectId);
|
|
172
|
-
const
|
|
208
|
+
const enrollment = await captureEnrollmentConsequence(action, params, projectId).catch(() => ({}));
|
|
209
|
+
const withDisplay = (value) => ({ ...value, ...enrollment, ...(display ? { display } : {}) });
|
|
173
210
|
const setup = isTesterRequest(action) ? await captureTesterRequestSnapshot(action, params, projectId) : null;
|
|
174
211
|
const snapshot = { version: 1, campaigns: [], steps: [], sites: [], recipient_count: null };
|
|
175
212
|
if (!TARGET_ACTIONS.has(action))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { effectiveApproval } from './approval-classes.js';
|
|
1
2
|
import { requestContext } from '../db.js';
|
|
2
3
|
import { getProjectId } from '../project.js';
|
|
3
4
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
@@ -81,6 +82,9 @@ export async function approveApproval(approvalId, transportClientName) {
|
|
|
81
82
|
if (!await humanApprovalEligible(approval.action, projectId, actor)) {
|
|
82
83
|
return refuse('approval_forbidden', 'This approval requires a person with the required project role.');
|
|
83
84
|
}
|
|
85
|
+
if (approval.params?.__approval_snapshot?.approval_channel_rule === 'human_only' || (await effectiveApproval(approval.action, approval.params)).channelRule === 'human_only') {
|
|
86
|
+
return refuse('human_decision_required', 'Use the dashboard link or confirm with your typed reply in chat.');
|
|
87
|
+
}
|
|
84
88
|
const name = await clientName(transportClientName);
|
|
85
89
|
const now = new Date().toISOString();
|
|
86
90
|
const decision = client.from('action_approvals')
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { effectiveApproval } from './approval-classes.js';
|
|
1
2
|
import { isApplyingTesterRequest } from './tester-policy.js';
|
|
2
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { campaignTriggerChanges, campaignTriggerRevision, triggerChangeNeedsApproval } from '@sendinel/domain';
|
|
4
5
|
import { db } from '../db.js';
|
|
5
6
|
import { getProjectId } from '../project.js';
|
|
6
7
|
import { logAudit } from '../audit.js';
|
|
7
|
-
import { assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
|
|
8
|
+
import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, checkApproval } from './action-approvals.js';
|
|
8
9
|
export const TRIGGER_EDIT_SELECT = 'id, name, type, status, trigger_type, trigger_event_name, trigger_tag, trigger_config, period_key, updated_at';
|
|
9
10
|
export const campaignEditError = (error, code) => ({ error, code });
|
|
10
11
|
/** All writes include the revision used for the approval decision, including ungated drafts. */
|
|
@@ -23,13 +24,16 @@ export async function finishCampaignEdit(action, input, existing, updates, reque
|
|
|
23
24
|
if (campaignTriggerChanges(existing, updates) && !['draft', 'active', 'paused'].includes(existing.status)) {
|
|
24
25
|
return campaignEditError(`Cannot change a ${existing.status} campaign trigger`, 'INVALID_STATUS');
|
|
25
26
|
}
|
|
26
|
-
if (isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
|
|
27
|
+
if (approvalSatisfied(action) || isApplyingTesterRequest(action) || !triggerChangeNeedsApproval(existing, updates))
|
|
27
28
|
return writeEdit(input.campaign_id, existing, updates);
|
|
28
29
|
const params = { campaign_id: input.campaign_id, input, previous_campaign: existing, updates };
|
|
29
30
|
const context = currentChannelContext();
|
|
30
|
-
const decision = assertChannelCanExecute(action, params, context);
|
|
31
|
+
const decision = await assertChannelCanExecute(action, params, context);
|
|
31
32
|
if (!decision.allowed)
|
|
32
33
|
return campaignEditError(decision.reason ?? 'Channel cannot request approval', 'FORBIDDEN');
|
|
34
|
+
const effective = await effectiveApproval(action, params);
|
|
35
|
+
if (effective.applied && effective.level === 'auto' && !effective.prompt)
|
|
36
|
+
return writeEdit(input.campaign_id, existing, updates);
|
|
33
37
|
// This trigger policy requires a human decision even if the integration uses autonomous mode.
|
|
34
38
|
return request(params);
|
|
35
39
|
}
|
|
@@ -57,7 +61,7 @@ export async function resumeCampaignEdit(action, approvalId, input) {
|
|
|
57
61
|
}
|
|
58
62
|
if (!Number.isFinite(Date.parse(approval.expires_at)) || Date.parse(approval.expires_at) <= Date.now())
|
|
59
63
|
return campaignEditError('Approval expired', 'APPROVAL_EXPIRED');
|
|
60
|
-
const decision = assertChannelCanExecute(action, params, currentChannelContext());
|
|
64
|
+
const decision = await assertChannelCanExecute(action, params, currentChannelContext());
|
|
61
65
|
if (!decision.allowed)
|
|
62
66
|
return campaignEditError(decision.reason ?? 'Channel cannot use approval', 'FORBIDDEN');
|
|
63
67
|
const check = await checkApproval(approvalId, { consume: true, action, params });
|
package/build/lib/db-error.js
CHANGED
|
@@ -94,6 +94,7 @@ export const API_USAGE_ERROR_CODE_MAP = {
|
|
|
94
94
|
'validation_failed': 'validation_failed',
|
|
95
95
|
'approval_not_cancellable': 'conflict',
|
|
96
96
|
'approval_not_pending': 'conflict',
|
|
97
|
+
'human_decision_required': 'forbidden',
|
|
97
98
|
'23505': 'conflict',
|
|
98
99
|
'conflict': 'conflict',
|
|
99
100
|
'content_changed_since_approval': 'conflict',
|
package/build/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isApprovalGatedAction } from './lib/approval-actions.js';
|
|
1
2
|
import { withToolErrors, toolError, installToolErrorBoundary } from './lib/db-error.js';
|
|
2
3
|
import { installApprovalCapabilityProbe } from './lib/approval-capabilities.js';
|
|
3
4
|
import { installApprovalApp } from './lib/approval-app.js';
|
|
@@ -7,7 +8,7 @@ import { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
|
7
8
|
import { testerRequestTool } from './lib/tester-requests.js';
|
|
8
9
|
export { TESTER_REQUEST_TOOLS } from './lib/tester-policy.js';
|
|
9
10
|
import { ADMIN_TOOLS, isHumanRequestableAdminTool } from './lib/admin-tools.js';
|
|
10
|
-
import {
|
|
11
|
+
import { withPreviewApproval, withApproval } from './lib/action-approvals.js';
|
|
11
12
|
import { registerAgentMailboxTools } from './tools/agent-mailboxes.js';
|
|
12
13
|
import { registerAnalyticsTools } from './tools/analytics.js';
|
|
13
14
|
import { registerCampaignTools } from './tools/campaigns.js';
|
|
@@ -318,6 +319,10 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
|
|
|
318
319
|
if (wouldBlock)
|
|
319
320
|
return; // omit from tool list entirely
|
|
320
321
|
}
|
|
322
|
+
if (isApprovalGatedAction(name)) {
|
|
323
|
+
const originalHandler = handler;
|
|
324
|
+
handler = (args, extra) => withPreviewApproval(name, originalHandler)(args, extra);
|
|
325
|
+
}
|
|
321
326
|
// ── Scope enforcement (only when scopes are provided) ─────────────
|
|
322
327
|
if (scopes !== null) {
|
|
323
328
|
const required = getRequiredScope(name);
|
package/build/tools/approvals.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { personApprovalLevel } from '../lib/approval-classes.js';
|
|
2
|
+
import { APPROVAL_LEVEL_MEANING } from '@sendinel/domain';
|
|
1
3
|
import { z } from 'zod';
|
|
2
4
|
import { db } from '../db.js';
|
|
3
5
|
import { getProjectId } from '../project.js';
|
|
@@ -9,6 +11,16 @@ import { approvalClientName } from '../lib/approval-capabilities.js';
|
|
|
9
11
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
10
12
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
11
13
|
export function registerApprovalTools(server, options) {
|
|
14
|
+
server.registerTool('get_approval_level', {
|
|
15
|
+
title: 'Get Approval Level',
|
|
16
|
+
description: 'Read your own approval setting. Only you can change it in dashboard settings; an AI cannot turn on Auto.',
|
|
17
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
18
|
+
inputSchema: {},
|
|
19
|
+
}, async () => {
|
|
20
|
+
const level = await personApprovalLevel();
|
|
21
|
+
return j({ level: level ?? 'default', applies_to_connection: level !== null,
|
|
22
|
+
meaning: level ? APPROVAL_LEVEL_MEANING[level] : 'Your connection keeps its usual approval checks.' });
|
|
23
|
+
});
|
|
12
24
|
server.registerTool('cancel_approval', {
|
|
13
25
|
title: 'Cancel Approval',
|
|
14
26
|
description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected; cannot approve or undo cancellation.',
|
|
@@ -289,6 +289,38 @@ export function registerCampaignSetTools(server) {
|
|
|
289
289
|
}, async (args) => {
|
|
290
290
|
const projectId = getProjectId();
|
|
291
291
|
const { approval_id } = args;
|
|
292
|
+
async function applyCampaigns(set, ids, approval_id, decidedBy) {
|
|
293
|
+
const activated = [];
|
|
294
|
+
const failed = [];
|
|
295
|
+
for (const c of set.campaigns) {
|
|
296
|
+
const { error } = await db()
|
|
297
|
+
.from('campaigns')
|
|
298
|
+
.update({ status: 'active', updated_at: new Date().toISOString() })
|
|
299
|
+
.eq('id', c.id)
|
|
300
|
+
.eq('project_id', projectId)
|
|
301
|
+
.in('status', ['draft', 'paused']);
|
|
302
|
+
if (error) {
|
|
303
|
+
failed.push({ campaign_id: c.id, ...dbToolError(error) });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
activated.push(c.id);
|
|
307
|
+
fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
|
|
308
|
+
}
|
|
309
|
+
await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: decidedBy ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
|
|
310
|
+
return j({
|
|
311
|
+
activated,
|
|
312
|
+
failed,
|
|
313
|
+
...(set.campaigns.some(c => c.type === 'broadcast') ? {
|
|
314
|
+
campaigns: previewOf(set.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), set.steps),
|
|
315
|
+
broadcast_activation: BROADCAST_ACTIVATION,
|
|
316
|
+
} : {}),
|
|
317
|
+
message: failed.length
|
|
318
|
+
? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
|
|
319
|
+
: set.campaigns.some(c => c.type === 'broadcast')
|
|
320
|
+
? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
|
|
321
|
+
: `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
292
324
|
if (approval_id) {
|
|
293
325
|
const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
|
|
294
326
|
if (extras.length)
|
|
@@ -318,36 +350,7 @@ export function registerCampaignSetTools(server) {
|
|
|
318
350
|
const check = await checkApproval(approval_id, { consume: true, action: 'activate_campaigns', params: stored });
|
|
319
351
|
if (!check.approved)
|
|
320
352
|
return j({ error: `Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`, code: 'validation_failed', approval_status: check.status });
|
|
321
|
-
|
|
322
|
-
const failed = [];
|
|
323
|
-
for (const c of current.campaigns) {
|
|
324
|
-
const { error } = await db()
|
|
325
|
-
.from('campaigns')
|
|
326
|
-
.update({ status: 'active', updated_at: new Date().toISOString() })
|
|
327
|
-
.eq('id', c.id)
|
|
328
|
-
.eq('project_id', projectId)
|
|
329
|
-
.in('status', ['draft', 'paused']);
|
|
330
|
-
if (error) {
|
|
331
|
-
failed.push({ campaign_id: c.id, ...dbToolError(error) });
|
|
332
|
-
continue;
|
|
333
|
-
}
|
|
334
|
-
activated.push(c.id);
|
|
335
|
-
fireWebhookEvent('campaign.activated', { campaign_id: c.id, campaign_name: c.name, status: 'active' }).catch(() => { });
|
|
336
|
-
}
|
|
337
|
-
await logAudit({ action: 'campaign.set_activated', resourceType: 'campaign_set', resourceId: approval_id, details: { approval_id, decided_by: check.decided_by ?? null, activated, failed, source: 'mcp' } }).catch(() => { });
|
|
338
|
-
return j({
|
|
339
|
-
activated,
|
|
340
|
-
failed,
|
|
341
|
-
...(current.campaigns.some(c => c.type === 'broadcast') ? {
|
|
342
|
-
campaigns: previewOf(current.campaigns.filter(c => activated.includes(c.id)).map(c => ({ ...c, status: 'active' })), current.steps),
|
|
343
|
-
broadcast_activation: BROADCAST_ACTIVATION,
|
|
344
|
-
} : {}),
|
|
345
|
-
message: failed.length
|
|
346
|
-
? `Activated ${activated.length} of ${ids.length}. ${failed.length} could not be activated; see failed.`
|
|
347
|
-
: current.campaigns.some(c => c.type === 'broadcast')
|
|
348
|
-
? `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. ${BROADCAST_ACTIVATION} Triggered campaigns send when their triggers fire.`
|
|
349
|
-
: `Activated ${activated.length} campaign${activated.length === 1 ? '' : 's'}. Each sends when its trigger fires for a contact who can get it.`,
|
|
350
|
-
});
|
|
353
|
+
return applyCampaigns(current, ids, approval_id, check.decided_by);
|
|
351
354
|
}
|
|
352
355
|
// Call 1: build one approval for the exact set.
|
|
353
356
|
let ids = args.campaign_ids ?? [];
|
|
@@ -391,9 +394,11 @@ export function registerCampaignSetTools(server) {
|
|
|
391
394
|
const contentWarnings = contentWarningView(readiness.warnings, readiness.lookup_errors);
|
|
392
395
|
const approvalParams = { campaign_ids: [...ids].sort(), content_hash: campaignSetContentHash(set.campaigns, set.steps) };
|
|
393
396
|
const channelContext = currentChannelContext();
|
|
394
|
-
const decision = assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
|
|
397
|
+
const decision = await assertChannelCanExecute('activate_campaigns', approvalParams, channelContext);
|
|
395
398
|
if (!decision.allowed)
|
|
396
399
|
return j({ error: decision.reason ?? 'This channel cannot request this action.', code: 'forbidden' });
|
|
400
|
+
if (decision.tier === 'autonomous')
|
|
401
|
+
return applyCampaigns(set, ids);
|
|
397
402
|
const names = set.campaigns.map((c) => c.name);
|
|
398
403
|
const approval = await requestApproval('activate_campaigns', approvalParams, undefined, channelContext, {
|
|
399
404
|
campaign_count: set.campaigns.length,
|
package/build/tools/campaigns.js
CHANGED
|
@@ -1055,7 +1055,7 @@ export function registerCampaignTools(server) {
|
|
|
1055
1055
|
const approvalParams = { campaign_id, status };
|
|
1056
1056
|
if (!approval_id) {
|
|
1057
1057
|
const channelContext = currentChannelContext();
|
|
1058
|
-
const decision = assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
|
|
1058
|
+
const decision = await assertChannelCanExecute('update_campaign_status', approvalParams, channelContext);
|
|
1059
1059
|
if (!decision.allowed)
|
|
1060
1060
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1061
1061
|
if (decision.tier !== 'autonomous') {
|
|
@@ -1366,7 +1366,7 @@ export function registerCampaignTools(server) {
|
|
|
1366
1366
|
});
|
|
1367
1367
|
}
|
|
1368
1368
|
const channelContext = currentChannelContext();
|
|
1369
|
-
const decision = assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
|
|
1369
|
+
const decision = await assertChannelCanExecute('broadcast_to_segment', approvalParams, channelContext);
|
|
1370
1370
|
if (!decision.allowed)
|
|
1371
1371
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1372
1372
|
if (decision.tier !== 'autonomous') {
|
|
@@ -1462,29 +1462,7 @@ export function registerCampaignTools(server) {
|
|
|
1462
1462
|
}, async (args) => {
|
|
1463
1463
|
const projectId = getProjectId();
|
|
1464
1464
|
const { approval_id } = args;
|
|
1465
|
-
|
|
1466
|
-
if (approval_id) {
|
|
1467
|
-
const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
|
|
1468
|
-
if (extras.length) {
|
|
1469
|
-
return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
|
|
1470
|
-
}
|
|
1471
|
-
const { data: row, error: rowErr } = await db()
|
|
1472
|
-
.from('action_approvals')
|
|
1473
|
-
.select('params')
|
|
1474
|
-
.eq('id', approval_id)
|
|
1475
|
-
.eq('project_id', projectId)
|
|
1476
|
-
.eq('action', 'send_broadcast')
|
|
1477
|
-
.maybeSingle();
|
|
1478
|
-
if (rowErr)
|
|
1479
|
-
return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
|
|
1480
|
-
if (!row)
|
|
1481
|
-
return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
|
|
1482
|
-
const stored = (row.params ?? {});
|
|
1483
|
-
const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
|
|
1484
|
-
if (!check.approved) {
|
|
1485
|
-
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1486
|
-
}
|
|
1487
|
-
const payload = (stored.payload ?? {});
|
|
1465
|
+
async function applyBroadcast(payload, approval_id, decidedBy) {
|
|
1488
1466
|
const contactIds = Array.isArray(payload.contact_ids) ? payload.contact_ids.map(String) : [];
|
|
1489
1467
|
if (payload.send_at && new Date(payload.send_at).getTime() <= Date.now()) {
|
|
1490
1468
|
// An approved future time that has now passed: send right away rather than silently skip.
|
|
@@ -1514,7 +1492,7 @@ export function registerCampaignTools(server) {
|
|
|
1514
1492
|
action: 'campaign.send_broadcast',
|
|
1515
1493
|
resourceType: 'campaign',
|
|
1516
1494
|
resourceId: executed.campaignId,
|
|
1517
|
-
details: { approval_id, decided_by:
|
|
1495
|
+
details: { approval_id, decided_by: decidedBy ?? null, site_id: payload.site_id, segment_id: payload.segment_id ?? null, contacts_enrolled: executed.enrolled, send_at: payload.send_at ?? null, source: 'mcp' },
|
|
1518
1496
|
}).catch(() => { });
|
|
1519
1497
|
fireWebhookEvent('campaign.activated', {
|
|
1520
1498
|
campaign_id: executed.campaignId,
|
|
@@ -1535,6 +1513,30 @@ export function registerCampaignTools(server) {
|
|
|
1535
1513
|
: `Sending: "${payload.subject}" is going to ${executed.enrolled} people now. Delivery happens over the next few minutes; check the campaign for results.`,
|
|
1536
1514
|
});
|
|
1537
1515
|
}
|
|
1516
|
+
// ── Call 2: execute exactly the approved, frozen payload. Nothing else is trusted. ──
|
|
1517
|
+
if (approval_id) {
|
|
1518
|
+
const extras = Object.entries(args).filter(([key, value]) => key !== 'approval_id' && value !== undefined).map(([key]) => key);
|
|
1519
|
+
if (extras.length) {
|
|
1520
|
+
return j({ error: `Call send_broadcast with only approval_id to send. Remove: ${extras.join(', ')}. To change anything, start over without approval_id.`, code: 'APPROVAL_CALL_TAKES_ONLY_APPROVAL_ID' });
|
|
1521
|
+
}
|
|
1522
|
+
const { data: row, error: rowErr } = await db()
|
|
1523
|
+
.from('action_approvals')
|
|
1524
|
+
.select('params')
|
|
1525
|
+
.eq('id', approval_id)
|
|
1526
|
+
.eq('project_id', projectId)
|
|
1527
|
+
.eq('action', 'send_broadcast')
|
|
1528
|
+
.maybeSingle();
|
|
1529
|
+
if (rowErr)
|
|
1530
|
+
return j({ ...dbToolError(rowErr), code: 'APPROVAL_LOOKUP_FAILED' });
|
|
1531
|
+
if (!row)
|
|
1532
|
+
return txt(`Approval ${approval_id} is not_found. Request a new approval.`);
|
|
1533
|
+
const stored = (row.params ?? {});
|
|
1534
|
+
const check = await checkApproval(approval_id, { consume: true, action: 'send_broadcast', params: stored });
|
|
1535
|
+
if (!check.approved) {
|
|
1536
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
1537
|
+
}
|
|
1538
|
+
return applyBroadcast((stored.payload ?? {}), approval_id, check.decided_by);
|
|
1539
|
+
}
|
|
1538
1540
|
// ── Call 1: resolve, render, count, request approval. Sends nothing. ──
|
|
1539
1541
|
const { audience, segment_id, brief, html, subject, body_text, site_id, send_at, name, send_class, brand_asset_ids } = args;
|
|
1540
1542
|
if (Boolean(audience) === Boolean(segment_id))
|
|
@@ -1641,9 +1643,11 @@ export function registerCampaignTools(server) {
|
|
|
1641
1643
|
};
|
|
1642
1644
|
const approvalParams = { payload };
|
|
1643
1645
|
const channelContext = currentChannelContext();
|
|
1644
|
-
const decision = assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
|
|
1646
|
+
const decision = await assertChannelCanExecute('send_broadcast', approvalParams, channelContext);
|
|
1645
1647
|
if (!decision.allowed)
|
|
1646
1648
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
1649
|
+
if (decision.tier === 'autonomous')
|
|
1650
|
+
return applyBroadcast(payload);
|
|
1647
1651
|
const approval = await requestApproval('send_broadcast', approvalParams, undefined, channelContext, {
|
|
1648
1652
|
subject: content.subject,
|
|
1649
1653
|
segment_name: segment_id ? segmentName : null,
|
|
@@ -2111,7 +2115,7 @@ Adapt your tone accordingly:
|
|
|
2111
2115
|
const channelContext = currentChannelContext();
|
|
2112
2116
|
let needsApproval = false;
|
|
2113
2117
|
if (confirmed) {
|
|
2114
|
-
const decision = assertChannelCanExecute('launch_campaign', requestParams, channelContext);
|
|
2118
|
+
const decision = await assertChannelCanExecute('launch_campaign', requestParams, channelContext);
|
|
2115
2119
|
if (!decision.allowed)
|
|
2116
2120
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
2117
2121
|
needsApproval = decision.tier !== 'autonomous';
|
|
@@ -3205,7 +3209,7 @@ Return ONLY valid JSON with this exact shape:
|
|
|
3205
3209
|
if (activate && confirmed) {
|
|
3206
3210
|
const approvalParams = { site_id, name, type, steps, segment_id, sends_per_hour, activate, send_class, unsub_footer_template, reentry_mode, period_key, ...triggerInput };
|
|
3207
3211
|
const channelContext = currentChannelContext();
|
|
3208
|
-
const decision = assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
3212
|
+
const decision = await assertChannelCanExecute('create_campaign_with_content', { activate }, channelContext);
|
|
3209
3213
|
if (!decision.allowed)
|
|
3210
3214
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
3211
3215
|
if (decision.tier !== 'autonomous') {
|
package/build/tools/connector.js
CHANGED
|
@@ -97,7 +97,7 @@ async function authorizeConnectorMutation(action, confirmed, approvalId, approva
|
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
const channelContext = currentChannelContext();
|
|
100
|
-
const decision = assertChannelCanExecute(action, approvalParams, channelContext);
|
|
100
|
+
const decision = await assertChannelCanExecute(action, approvalParams, channelContext);
|
|
101
101
|
if (!decision.allowed)
|
|
102
102
|
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
103
103
|
if (decision.tier === 'autonomous')
|
package/build/tools/contacts.js
CHANGED
|
@@ -2471,7 +2471,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2471
2471
|
const inactive = (contacts ?? []).filter((c) => !suppressedSet.has(c.email));
|
|
2472
2472
|
if (!dry_run && inactive.length > 0) {
|
|
2473
2473
|
const channelContext = currentChannelContext();
|
|
2474
|
-
const decision = assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
|
|
2474
|
+
const decision = await assertChannelCanExecute('identify_inactive', approvalParams, channelContext);
|
|
2475
2475
|
if (!decision.allowed)
|
|
2476
2476
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
2477
2477
|
if (decision.tier !== 'autonomous') {
|
|
@@ -2686,7 +2686,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2686
2686
|
};
|
|
2687
2687
|
if (!approval_id) {
|
|
2688
2688
|
const channelContext = currentChannelContext();
|
|
2689
|
-
const decision = assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
|
|
2689
|
+
const decision = await assertChannelCanExecute('merge_subscribers', { target_id, source_ids }, channelContext);
|
|
2690
2690
|
if (!decision.allowed)
|
|
2691
2691
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
2692
2692
|
const approval = await requestApproval('merge_subscribers', approvalParams, undefined, channelContext);
|
package/build/tools/data.js
CHANGED
|
@@ -421,7 +421,7 @@ export function registerDataTools(server) {
|
|
|
421
421
|
});
|
|
422
422
|
}
|
|
423
423
|
const channelContext = currentChannelContext();
|
|
424
|
-
const decision = assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
|
|
424
|
+
const decision = await assertChannelCanExecute('delete_provider_credential', approvalParams, channelContext);
|
|
425
425
|
if (!decision.allowed)
|
|
426
426
|
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
427
427
|
if (decision.tier !== 'autonomous') {
|
|
@@ -868,7 +868,7 @@ export function registerDataTools(server) {
|
|
|
868
868
|
}
|
|
869
869
|
const approvalParams = { connection_id };
|
|
870
870
|
const channelContext = currentChannelContext();
|
|
871
|
-
const decision = assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
|
|
871
|
+
const decision = await assertChannelCanExecute('rotate_custom_webhook_secret', approvalParams, channelContext);
|
|
872
872
|
if (!decision.allowed)
|
|
873
873
|
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
874
874
|
if (decision.tier !== 'autonomous') {
|
|
@@ -1034,7 +1034,7 @@ export function registerDeliveryOpsTools(server) {
|
|
|
1034
1034
|
});
|
|
1035
1035
|
}
|
|
1036
1036
|
const channelContext = currentChannelContext();
|
|
1037
|
-
const decision = assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
|
|
1037
|
+
const decision = await assertChannelCanExecute('publish_dmarc_record', approvalParams, channelContext);
|
|
1038
1038
|
if (!decision.allowed)
|
|
1039
1039
|
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
1040
1040
|
if (decision.tier !== 'autonomous') {
|
package/build/tools/gdpr.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { logAudit } from '../audit.js';
|
|
5
|
-
import { assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
5
|
+
import { approvalSatisfied, assertChannelCanExecute, currentChannelContext, requestApproval, checkApproval } from '../lib/action-approvals.js';
|
|
6
6
|
import { dbToolError } from '../lib/db-error.js';
|
|
7
7
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
8
8
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
@@ -35,7 +35,7 @@ export function registerGdprTools(server) {
|
|
|
35
35
|
// Human approval required (always, even in autonomous mode — protected tier)
|
|
36
36
|
if (!approval_id) {
|
|
37
37
|
const channelContext = currentChannelContext();
|
|
38
|
-
const decision = assertChannelCanExecute('delete_subscriber_data', approvalParams, channelContext);
|
|
38
|
+
const decision = await assertChannelCanExecute('delete_subscriber_data', approvalParams, channelContext);
|
|
39
39
|
if (!decision.allowed)
|
|
40
40
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
41
41
|
const approval = await requestApproval('delete_subscriber_data', approvalParams, undefined, channelContext);
|
|
@@ -182,20 +182,22 @@ export function registerGdprTools(server) {
|
|
|
182
182
|
request_type: request.request_type,
|
|
183
183
|
email: request.email,
|
|
184
184
|
};
|
|
185
|
-
if (!
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
185
|
+
if (!approvalSatisfied('action_ccpa_request')) {
|
|
186
|
+
if (!approval_id) {
|
|
187
|
+
const channelContext = currentChannelContext();
|
|
188
|
+
const decision = await assertChannelCanExecute('action_ccpa_request', approvalParams, channelContext);
|
|
189
|
+
if (!decision.allowed)
|
|
190
|
+
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
191
|
+
return j(await requestApproval('action_ccpa_request', approvalParams, undefined, channelContext));
|
|
192
|
+
}
|
|
193
|
+
const check = await checkApproval(approval_id, {
|
|
194
|
+
consume: true,
|
|
195
|
+
action: 'action_ccpa_request',
|
|
196
|
+
params: approvalParams,
|
|
197
|
+
});
|
|
198
|
+
if (!check.approved) {
|
|
199
|
+
return txt(`Approval ${approval_id} is ${check.status}. ${check.status === 'pending' ? 'Waiting for human approval via dashboard.' : 'Request a new approval.'}`);
|
|
200
|
+
}
|
|
199
201
|
}
|
|
200
202
|
const { data: contact, error: contactErr } = await db()
|
|
201
203
|
.from('subscribers')
|
package/build/tools/lists.js
CHANGED
|
@@ -242,7 +242,7 @@ export function registerListTools(server) {
|
|
|
242
242
|
});
|
|
243
243
|
}
|
|
244
244
|
const channelContext = currentChannelContext();
|
|
245
|
-
const decision = assertChannelCanExecute('add_list_members', approvalParams, channelContext);
|
|
245
|
+
const decision = await assertChannelCanExecute('add_list_members', approvalParams, channelContext);
|
|
246
246
|
if (!decision.allowed)
|
|
247
247
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
248
248
|
if (decision.tier !== 'autonomous') {
|
|
@@ -318,7 +318,7 @@ export function registerListTools(server) {
|
|
|
318
318
|
});
|
|
319
319
|
}
|
|
320
320
|
const channelContext = currentChannelContext();
|
|
321
|
-
const decision = assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
|
|
321
|
+
const decision = await assertChannelCanExecute('remove_list_members', approvalParams, channelContext);
|
|
322
322
|
if (!decision.allowed)
|
|
323
323
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
324
324
|
if (decision.tier !== 'autonomous') {
|
|
@@ -400,7 +400,7 @@ export function registerListTools(server) {
|
|
|
400
400
|
});
|
|
401
401
|
}
|
|
402
402
|
const channelContext = currentChannelContext();
|
|
403
|
-
const decision = assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
|
|
403
|
+
const decision = await assertChannelCanExecute('add_list_members_by_filter', approvalParams, channelContext);
|
|
404
404
|
if (!decision.allowed)
|
|
405
405
|
return txt(decision.reason ?? 'This channel cannot request this action.');
|
|
406
406
|
if (decision.tier !== 'autonomous') {
|
package/build/tools/org.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getProjectId } from '../project.js';
|
|
|
6
6
|
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
7
|
import { callSendinelV1 } from '../lib/v1-client.js';
|
|
8
8
|
import { dbToolError } from '../lib/db-error.js';
|
|
9
|
+
import { checkPlanLimit } from '../plan-limits.js';
|
|
9
10
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
10
11
|
const PROVIDER_SWITCH_V1_PROVIDERS = ['resend', 'ses', 'elastic-email'];
|
|
11
12
|
async function callProviderSwitchApi(path, init) {
|
|
@@ -239,7 +240,7 @@ export function registerOrgTools(server) {
|
|
|
239
240
|
return j({ error: 'Cannot remove the last owner', code: 'LAST_OWNER' });
|
|
240
241
|
}
|
|
241
242
|
const channelContext = currentChannelContext();
|
|
242
|
-
const decision = assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
|
|
243
|
+
const decision = await assertChannelCanExecute('remove_team_member', approvalParams, channelContext);
|
|
243
244
|
if (!decision.allowed)
|
|
244
245
|
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
245
246
|
if (decision.tier !== 'autonomous') {
|
|
@@ -308,7 +309,7 @@ export function registerOrgTools(server) {
|
|
|
308
309
|
});
|
|
309
310
|
}
|
|
310
311
|
const channelContext = currentChannelContext();
|
|
311
|
-
const decision = assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
|
|
312
|
+
const decision = await assertChannelCanExecute('set_team_member_role', approvalParams, channelContext);
|
|
312
313
|
if (!decision.allowed)
|
|
313
314
|
return j({ error: decision.reason ?? 'This channel cannot request this action.' });
|
|
314
315
|
if (decision.tier !== 'autonomous') {
|
|
@@ -545,9 +546,13 @@ export function registerOrgTools(server) {
|
|
|
545
546
|
const { data: project } = await db().from('projects').select('plan').eq('id', projectId).maybeSingle();
|
|
546
547
|
const plan = project?.plan ?? 'free';
|
|
547
548
|
const limits = PLAN_LIMITS[plan] ?? PLAN_LIMITS.free ?? {};
|
|
548
|
-
const [contacts, sends, { count: sites }] = await Promise.all([
|
|
549
|
+
const [contacts, sends, drafts, { count: sites }] = await Promise.all([
|
|
549
550
|
readPlanUsage(db(), projectId, "contacts"),
|
|
550
551
|
readPlanUsage(db(), projectId, "monthly_sends"),
|
|
552
|
+
checkPlanLimit('ai_drafts').then((result) => ({ data: result, error: null })).catch((error) => ({
|
|
553
|
+
data: null,
|
|
554
|
+
error: error instanceof Error ? error.message : 'Unable to read draft usage',
|
|
555
|
+
})),
|
|
551
556
|
db().from('sites').select('id', { count: 'exact', head: true }).eq('project_id', projectId),
|
|
552
557
|
]);
|
|
553
558
|
if (contacts.error || sends.error || contacts.data?.error || sends.data?.error || !contacts.data || !sends.data) {
|
|
@@ -558,7 +563,9 @@ export function registerOrgTools(server) {
|
|
|
558
563
|
contacts: { used: contacts.data.current, limit: contacts.data.limit },
|
|
559
564
|
monthly_sends: { used: sends.data.current, limit: sends.data.limit },
|
|
560
565
|
sites: { used: sites ?? 0, limit: limits.sites ?? null },
|
|
561
|
-
ai_drafts:
|
|
566
|
+
ai_drafts: typeof drafts.data?.current === 'number'
|
|
567
|
+
? { used: drafts.data.current, limit: limits.ai_drafts ?? null }
|
|
568
|
+
: { used: null, limit: limits.ai_drafts ?? null, reason: 'Unable to read draft usage' },
|
|
562
569
|
log_retention_days: limits.log_retention_days ?? null,
|
|
563
570
|
});
|
|
564
571
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.165",
|
|
4
4
|
"description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
|
|
5
5
|
"mcpName": "ai.sendinel/mcp-server",
|
|
6
6
|
"type": "module",
|