@sendinel/mcp-server 1.0.3 → 1.0.5
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/build/audit.d.ts +1 -0
- package/build/audit.js +9 -3
- package/build/auth.d.ts +10 -0
- package/build/auth.js +82 -9
- package/build/db.d.ts +12 -0
- package/build/db.js +37 -2
- package/build/index.js +38 -12
- package/build/lib/action-approvals.d.ts +28 -1
- package/build/lib/action-approvals.js +89 -2
- package/build/lib/brand-asset-persist.d.ts +35 -0
- package/build/lib/brand-asset-persist.js +161 -0
- package/build/lib/brand-asset-usage.d.ts +13 -0
- package/build/lib/brand-asset-usage.js +46 -0
- package/build/lib/email-provider.d.ts +19 -0
- package/build/lib/email-provider.js +281 -0
- package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
- package/build/lib/ssrf-pinned-fetch.js +101 -0
- package/build/lib/survey-link-resolver.d.ts +6 -0
- package/build/lib/survey-link-resolver.js +23 -0
- package/build/lib/svg-sanitize.d.ts +2 -0
- package/build/lib/svg-sanitize.js +110 -0
- package/build/lib/webhook-events.d.ts +1 -1
- package/build/scoped-token.d.ts +11 -0
- package/build/scoped-token.js +59 -0
- package/build/server.js +30 -9
- package/build/tools/ab-testing.js +28 -0
- package/build/tools/advisor.js +52 -2
- package/build/tools/ai-work.d.ts +2 -0
- package/build/tools/ai-work.js +460 -0
- package/build/tools/analytics.d.ts +1 -0
- package/build/tools/analytics.js +203 -0
- package/build/tools/approvals.js +15 -1
- package/build/tools/automations.js +288 -65
- package/build/tools/brand-kit.d.ts +2 -0
- package/build/tools/brand-kit.js +277 -0
- package/build/tools/campaigns.js +900 -69
- package/build/tools/compound.js +28 -0
- package/build/tools/contacts.js +338 -17
- package/build/tools/content.js +266 -1
- package/build/tools/data-proposals.js +21 -0
- package/build/tools/data.js +121 -5
- package/build/tools/delivery-ops.js +84 -0
- package/build/tools/drafts.js +64 -1
- package/build/tools/forms.js +42 -9
- package/build/tools/gdpr.js +22 -3
- package/build/tools/newsletters.d.ts +2 -0
- package/build/tools/newsletters.js +583 -0
- package/build/tools/org.js +297 -3
- package/build/tools/segments.js +45 -3
- package/build/tools/short-links.d.ts +2 -0
- package/build/tools/short-links.js +157 -0
- package/build/tools/sites.js +35 -0
- package/build/tools/sms.js +49 -0
- package/build/tools/social-posts.js +81 -5
- package/build/tools/surveys.d.ts +2 -0
- package/build/tools/surveys.js +222 -0
- package/build/tools/templates.js +78 -2
- package/build/tools/warmup.js +28 -0
- package/build/tools/webhooks.js +29 -0
- package/package.json +10 -4
package/build/audit.d.ts
CHANGED
package/build/audit.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { db } from './db.js';
|
|
1
|
+
import { db, requestContext } from './db.js';
|
|
2
2
|
import { getProjectId } from './project.js';
|
|
3
3
|
/**
|
|
4
4
|
* Log a consent record for GDPR compliance.
|
|
@@ -24,15 +24,21 @@ export async function logConsent(opts) {
|
|
|
24
24
|
*/
|
|
25
25
|
export async function logAudit(opts) {
|
|
26
26
|
const projectId = getProjectId();
|
|
27
|
+
const context = requestContext();
|
|
27
28
|
await db()
|
|
28
29
|
.from('audit_log')
|
|
29
30
|
.insert({
|
|
30
31
|
project_id: projectId,
|
|
31
|
-
actor_type: 'mcp',
|
|
32
|
+
actor_type: context?.actorType ?? 'mcp',
|
|
33
|
+
actor_id: context?.actorId ?? null,
|
|
32
34
|
action: opts.action,
|
|
33
35
|
resource_type: opts.resourceType,
|
|
34
36
|
resource_id: opts.resourceId ?? null,
|
|
35
|
-
details:
|
|
37
|
+
details: {
|
|
38
|
+
...(opts.details ?? {}),
|
|
39
|
+
...(context?.workspaceId ? { workspace_id: context.workspaceId } : {}),
|
|
40
|
+
},
|
|
41
|
+
channel: opts.channel ?? context?.channel ?? 'mcp',
|
|
36
42
|
})
|
|
37
43
|
.then(() => { }, () => { }); // fire-and-forget
|
|
38
44
|
}
|
package/build/auth.d.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
export type ProjectPlan = 'free' | 'byod' | 'managed' | 'agency-plus' | 'enterprise';
|
|
2
|
+
export type ApprovalMode = 'standard' | 'autonomous';
|
|
2
3
|
export interface ApiKeyInfo {
|
|
3
4
|
projectId: string;
|
|
4
5
|
scopes: string[];
|
|
5
6
|
toolGroups: string[];
|
|
6
7
|
approvalMode: string;
|
|
8
|
+
authMethod?: 'api_key' | 'scoped_jwt';
|
|
9
|
+
actsAsUserId?: string | null;
|
|
10
|
+
workspaceId?: string | null;
|
|
11
|
+
}
|
|
12
|
+
export declare class McpAuthenticationError extends Error {
|
|
13
|
+
readonly status: 401 | 403 | 503;
|
|
14
|
+
readonly code: string;
|
|
15
|
+
constructor(message: string, status: 401 | 403 | 503, code: string);
|
|
7
16
|
}
|
|
8
17
|
export declare function resolveToolGroupsForPlan(plan: string | null | undefined, requested: string[] | null | undefined): string[];
|
|
9
18
|
export declare function serviceKeyInfo(projectId: string): Promise<ApiKeyInfo>;
|
|
10
19
|
export declare function validateKey(key: string): Promise<ApiKeyInfo | null>;
|
|
20
|
+
export declare function authenticateBearer(token: string, overrideProjectId?: string): Promise<ApiKeyInfo | null>;
|
package/build/auth.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { adminDb } from './db.js';
|
|
2
2
|
import { ALL_TOOL_GROUPS } from './server.js';
|
|
3
|
+
import { looksLikeScopedJwt, scopedJwtVerificationEnabled, verifyScopedToken, } from './scoped-token.js';
|
|
3
4
|
const BYOD_DEFAULT_TOOL_GROUPS = ['contacts', 'templates', 'analytics'];
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
export class McpAuthenticationError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
code;
|
|
8
|
+
constructor(message, status, code) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.code = code;
|
|
7
12
|
}
|
|
13
|
+
}
|
|
14
|
+
function defaultToolGroupsForPlan(plan) {
|
|
8
15
|
switch (plan) {
|
|
9
16
|
case 'byod':
|
|
10
17
|
return BYOD_DEFAULT_TOOL_GROUPS.slice();
|
|
@@ -17,14 +24,28 @@ export function resolveToolGroupsForPlan(plan, requested) {
|
|
|
17
24
|
return [];
|
|
18
25
|
}
|
|
19
26
|
}
|
|
20
|
-
|
|
27
|
+
function planAllowsExplicitToolGroups(plan) {
|
|
28
|
+
return plan === 'byod' || plan === 'managed' || plan === 'agency-plus' || plan === 'enterprise';
|
|
29
|
+
}
|
|
30
|
+
export function resolveToolGroupsForPlan(plan, requested) {
|
|
31
|
+
if (requested && requested.length > 0) {
|
|
32
|
+
if (!planAllowsExplicitToolGroups(plan))
|
|
33
|
+
return defaultToolGroupsForPlan(plan);
|
|
34
|
+
return requested.filter((group) => ALL_TOOL_GROUPS.includes(group));
|
|
35
|
+
}
|
|
36
|
+
return defaultToolGroupsForPlan(plan);
|
|
37
|
+
}
|
|
38
|
+
async function getProjectAuthDefaults(projectId) {
|
|
21
39
|
const { data } = await adminDb()
|
|
22
40
|
.schema('email')
|
|
23
41
|
.from('projects')
|
|
24
|
-
.select('plan')
|
|
42
|
+
.select('plan, approval_mode')
|
|
25
43
|
.eq('id', projectId)
|
|
26
44
|
.maybeSingle();
|
|
27
|
-
return
|
|
45
|
+
return {
|
|
46
|
+
plan: data?.plan ?? 'free',
|
|
47
|
+
approvalMode: data?.approval_mode ?? 'standard',
|
|
48
|
+
};
|
|
28
49
|
}
|
|
29
50
|
// First-party service auth for the MCP lane: the shared SYNCHRONEX_SERVICE_TOKEN
|
|
30
51
|
// acts on an explicitly-named project (no per-project key lookup). Tool access is
|
|
@@ -32,12 +53,12 @@ async function getProjectPlan(projectId) {
|
|
|
32
53
|
// the project is entitled to — never more. Mirrors authenticateV1's service path
|
|
33
54
|
// on the dashboard side.
|
|
34
55
|
export async function serviceKeyInfo(projectId) {
|
|
35
|
-
const plan = await
|
|
56
|
+
const { plan, approvalMode } = await getProjectAuthDefaults(projectId);
|
|
36
57
|
return {
|
|
37
58
|
projectId,
|
|
38
59
|
scopes: ['admin'],
|
|
39
60
|
toolGroups: resolveToolGroupsForPlan(plan, null),
|
|
40
|
-
approvalMode
|
|
61
|
+
approvalMode,
|
|
41
62
|
};
|
|
42
63
|
}
|
|
43
64
|
export async function validateKey(key) {
|
|
@@ -47,7 +68,7 @@ export async function validateKey(key) {
|
|
|
47
68
|
if (error || !data?.length)
|
|
48
69
|
return null;
|
|
49
70
|
const row = data[0];
|
|
50
|
-
const plan = await
|
|
71
|
+
const { plan } = await getProjectAuthDefaults(row.project_id);
|
|
51
72
|
return {
|
|
52
73
|
projectId: row.project_id,
|
|
53
74
|
scopes: row.scopes,
|
|
@@ -55,3 +76,55 @@ export async function validateKey(key) {
|
|
|
55
76
|
approvalMode: row.approval_mode ?? 'standard',
|
|
56
77
|
};
|
|
57
78
|
}
|
|
79
|
+
export async function authenticateBearer(token, overrideProjectId) {
|
|
80
|
+
if (scopedJwtVerificationEnabled() && looksLikeScopedJwt(token)) {
|
|
81
|
+
let claims;
|
|
82
|
+
try {
|
|
83
|
+
claims = await verifyScopedToken(token);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new McpAuthenticationError('Invalid or expired token', 401, 'INVALID_SCOPED_TOKEN');
|
|
87
|
+
}
|
|
88
|
+
if (claims.scope === 'data'
|
|
89
|
+
&& overrideProjectId
|
|
90
|
+
&& overrideProjectId !== claims.projectId) {
|
|
91
|
+
throw new McpAuthenticationError('Project header conflicts with signed token', 403, 'PROJECT_SCOPE_MISMATCH');
|
|
92
|
+
}
|
|
93
|
+
const projectId = claims.scope === 'service:provision'
|
|
94
|
+
? (overrideProjectId || claims.projectId)
|
|
95
|
+
: claims.projectId;
|
|
96
|
+
if (!projectId) {
|
|
97
|
+
throw new McpAuthenticationError('Token missing project scope', 403, 'INSUFFICIENT_SCOPE');
|
|
98
|
+
}
|
|
99
|
+
const info = await serviceKeyInfo(projectId);
|
|
100
|
+
const { error: auditError } = await adminDb()
|
|
101
|
+
.schema('email')
|
|
102
|
+
.from('audit_log')
|
|
103
|
+
.insert({
|
|
104
|
+
project_id: projectId,
|
|
105
|
+
actor_type: 'synchronex_scoped_jwt',
|
|
106
|
+
actor_id: claims.userId,
|
|
107
|
+
action: 'mcp.authenticate',
|
|
108
|
+
resource_type: 'mcp_transport',
|
|
109
|
+
details: {
|
|
110
|
+
workspace_id: claims.workspaceId,
|
|
111
|
+
token_scope: claims.scope,
|
|
112
|
+
},
|
|
113
|
+
channel: 'synchronex_scoped_jwt',
|
|
114
|
+
});
|
|
115
|
+
if (auditError) {
|
|
116
|
+
throw new McpAuthenticationError('Scoped request audit unavailable', 503, 'AUDIT_UNAVAILABLE');
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
...info,
|
|
120
|
+
authMethod: 'scoped_jwt',
|
|
121
|
+
actsAsUserId: claims.userId,
|
|
122
|
+
workspaceId: claims.workspaceId,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
let info = await validateKey(token);
|
|
126
|
+
if (info && overrideProjectId && info.scopes?.includes('service:provision')) {
|
|
127
|
+
info = await serviceKeyInfo(overrideProjectId);
|
|
128
|
+
}
|
|
129
|
+
return info;
|
|
130
|
+
}
|
package/build/db.d.ts
CHANGED
|
@@ -18,7 +18,13 @@ export declare function setProjectClient(client: any): void;
|
|
|
18
18
|
export declare function runWithClient<T>(client: any, fn: () => Promise<T>, request?: {
|
|
19
19
|
projectId?: string;
|
|
20
20
|
approvalMode?: string;
|
|
21
|
+
channel?: string;
|
|
22
|
+
channelVerified?: boolean;
|
|
23
|
+
actorType?: string;
|
|
24
|
+
actorId?: string | null;
|
|
25
|
+
workspaceId?: string | null;
|
|
21
26
|
}): Promise<T>;
|
|
27
|
+
export declare function runWithToolContext<T>(toolGroup: string, toolName: string, fn: () => Promise<T> | T): Promise<T>;
|
|
22
28
|
/**
|
|
23
29
|
* Request-scoped identity set via runWithClient() (HTTP transport).
|
|
24
30
|
* Undefined under stdio, where the process env is the single-project source of truth.
|
|
@@ -26,7 +32,13 @@ export declare function runWithClient<T>(client: any, fn: () => Promise<T>, requ
|
|
|
26
32
|
export declare function requestContext(): {
|
|
27
33
|
projectId?: string;
|
|
28
34
|
approvalMode?: string;
|
|
35
|
+
channel?: string;
|
|
36
|
+
channelVerified?: boolean;
|
|
37
|
+
actorType?: string;
|
|
38
|
+
actorId?: string | null;
|
|
39
|
+
workspaceId?: string | null;
|
|
29
40
|
} | undefined;
|
|
41
|
+
export declare function projectClientForWriteback(): any | null;
|
|
30
42
|
/**
|
|
31
43
|
* Returns the project client appropriate for the current execution context:
|
|
32
44
|
* 1. Per-request store (HTTP): set via runWithClient() — safe under concurrency.
|
package/build/db.js
CHANGED
|
@@ -48,13 +48,45 @@ export async function runWithClient(
|
|
|
48
48
|
client, fn, request) {
|
|
49
49
|
return _requestStore.run({ client, ...request }, fn);
|
|
50
50
|
}
|
|
51
|
+
export async function runWithToolContext(toolGroup, toolName, fn) {
|
|
52
|
+
const current = _requestStore.getStore();
|
|
53
|
+
if (!current)
|
|
54
|
+
return await _requestStore.run({ client: _processClient, toolGroup, toolName }, fn);
|
|
55
|
+
return await _requestStore.run({ ...current, toolGroup, toolName }, fn);
|
|
56
|
+
}
|
|
51
57
|
/**
|
|
52
58
|
* Request-scoped identity set via runWithClient() (HTTP transport).
|
|
53
59
|
* Undefined under stdio, where the process env is the single-project source of truth.
|
|
54
60
|
*/
|
|
55
61
|
export function requestContext() {
|
|
56
62
|
const ctx = _requestStore.getStore();
|
|
57
|
-
return ctx
|
|
63
|
+
return ctx
|
|
64
|
+
? {
|
|
65
|
+
projectId: ctx.projectId,
|
|
66
|
+
approvalMode: ctx.approvalMode,
|
|
67
|
+
channel: ctx.channel,
|
|
68
|
+
channelVerified: ctx.channelVerified,
|
|
69
|
+
actorType: ctx.actorType,
|
|
70
|
+
actorId: ctx.actorId,
|
|
71
|
+
workspaceId: ctx.workspaceId,
|
|
72
|
+
}
|
|
73
|
+
: undefined;
|
|
74
|
+
}
|
|
75
|
+
// Explicit BYOD write-back accessor. Contact tools continue to use db() for
|
|
76
|
+
// central reads/writes; this exposes the customer client only where a tool
|
|
77
|
+
// deliberately mirrors a completed central mutation.
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
export function projectClientForWriteback() {
|
|
80
|
+
const ctx = _requestStore.getStore();
|
|
81
|
+
return ctx?.client ?? _processClient ?? null;
|
|
82
|
+
}
|
|
83
|
+
const BYOD_TOOL_NAMES = new Set(['create_blog_post', 'list_blog_posts', 'list_blog_destinations']);
|
|
84
|
+
function shouldUseProjectClient(ctx) {
|
|
85
|
+
if (!ctx?.client)
|
|
86
|
+
return false;
|
|
87
|
+
if (ctx.toolName && BYOD_TOOL_NAMES.has(ctx.toolName))
|
|
88
|
+
return true;
|
|
89
|
+
return false;
|
|
58
90
|
}
|
|
59
91
|
/**
|
|
60
92
|
* Returns the project client appropriate for the current execution context:
|
|
@@ -66,5 +98,8 @@ export function requestContext() {
|
|
|
66
98
|
*/
|
|
67
99
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
68
100
|
export function db() {
|
|
69
|
-
|
|
101
|
+
const ctx = _requestStore.getStore();
|
|
102
|
+
if (shouldUseProjectClient(ctx))
|
|
103
|
+
return ctx?.client;
|
|
104
|
+
return adminDb();
|
|
70
105
|
}
|
package/build/index.js
CHANGED
|
@@ -9,8 +9,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
|
|
9
9
|
import express from 'express';
|
|
10
10
|
import { registerAllTools } from './server.js';
|
|
11
11
|
import { runWithClient } from './db.js';
|
|
12
|
-
import { initProjectClient,
|
|
13
|
-
import {
|
|
12
|
+
import { initProjectClient, resolveProjectClientStrict, TenantResolutionError } from './byod-client.js';
|
|
13
|
+
import { authenticateBearer, McpAuthenticationError, validateKey } from './auth.js';
|
|
14
14
|
const transport = process.env.MCP_TRANSPORT ?? 'stdio';
|
|
15
15
|
async function runStdio() {
|
|
16
16
|
const server = new McpServer({ name: 'sendinel', version: '0.1.0' });
|
|
@@ -51,18 +51,22 @@ async function runHttp() {
|
|
|
51
51
|
return;
|
|
52
52
|
}
|
|
53
53
|
const key = authHeader.slice(7);
|
|
54
|
-
|
|
54
|
+
const overrideProjectId = req.headers['x-sendinel-project-id']?.trim();
|
|
55
|
+
let info;
|
|
56
|
+
try {
|
|
57
|
+
info = await authenticateBearer(key, overrideProjectId);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof McpAuthenticationError) {
|
|
61
|
+
res.status(error.status).json({ error: error.message, code: error.code });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
55
66
|
if (!info) {
|
|
56
67
|
res.status(401).json({ error: 'Invalid or revoked API key' });
|
|
57
68
|
return;
|
|
58
69
|
}
|
|
59
|
-
// First-party service key (service:provision scope) may target any project
|
|
60
|
-
// via X-Sendinel-Project-Id, so Synchronex drives the bundled MCP lane
|
|
61
|
-
// without storing a per-project key. Other keys ignore the header.
|
|
62
|
-
const overrideProjectId = req.headers['x-sendinel-project-id']?.trim();
|
|
63
|
-
if (overrideProjectId && info.scopes?.includes('service:provision')) {
|
|
64
|
-
info = await serviceKeyInfo(overrideProjectId);
|
|
65
|
-
}
|
|
66
70
|
req._apiKeyInfo = info;
|
|
67
71
|
next();
|
|
68
72
|
});
|
|
@@ -72,7 +76,21 @@ async function runHttp() {
|
|
|
72
76
|
// runWithClient() binds it — plus projectId/approvalMode — to this request's
|
|
73
77
|
// AsyncLocalStorage context. Nothing request-scoped may be written to
|
|
74
78
|
// process.env here: it is process-global and leaks across concurrent tenants.
|
|
75
|
-
|
|
79
|
+
let projectClient;
|
|
80
|
+
try {
|
|
81
|
+
projectClient = await resolveProjectClientStrict(info.projectId);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof TenantResolutionError) {
|
|
85
|
+
res.status(424).json({
|
|
86
|
+
error: 'Project-level Supabase connector is not available',
|
|
87
|
+
code: 'PROJECT_CONNECTOR_UNAVAILABLE',
|
|
88
|
+
detail: err.message,
|
|
89
|
+
});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
76
94
|
await runWithClient(projectClient, async () => {
|
|
77
95
|
const server = new McpServer({ name: 'sendinel', version: '0.1.0' });
|
|
78
96
|
registerAllTools(server, {
|
|
@@ -82,7 +100,15 @@ async function runHttp() {
|
|
|
82
100
|
const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
83
101
|
await server.connect(t);
|
|
84
102
|
await t.handleRequest(req, res, req.body);
|
|
85
|
-
}, {
|
|
103
|
+
}, {
|
|
104
|
+
projectId: info.projectId,
|
|
105
|
+
approvalMode: info.approvalMode,
|
|
106
|
+
channel: info.authMethod === 'scoped_jwt' ? 'synchronex_scoped_jwt' : 'mcp_api_key',
|
|
107
|
+
channelVerified: true,
|
|
108
|
+
actorType: info.authMethod === 'scoped_jwt' ? 'synchronex_scoped_jwt' : 'mcp',
|
|
109
|
+
actorId: info.actsAsUserId ?? null,
|
|
110
|
+
workspaceId: info.workspaceId ?? null,
|
|
111
|
+
});
|
|
86
112
|
});
|
|
87
113
|
app.get('/health', (_req, res) => res.json({ ok: true }));
|
|
88
114
|
app.listen(port, () => {
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
export type ApprovalTier = 'autonomous' | 'gated' | 'protected';
|
|
2
2
|
export declare function getApprovalTier(action: string, params?: Record<string, unknown>): ApprovalTier;
|
|
3
|
+
/**
|
|
4
|
+
* The surface that originated an MCP tool call. Used to decide which tiers of
|
|
5
|
+
* action a given request is even allowed to *request* approval for — separate
|
|
6
|
+
* from who ultimately *decides* the approval (see `decided_via` and the
|
|
7
|
+
* `protected_requires_authenticated_decision` DB constraint).
|
|
8
|
+
*/
|
|
9
|
+
export type ChannelKind = 'dashboard_session' | 'mcp_oauth_session' | 'mcp_api_key' | 'slack_command' | 'sms_inbound' | 'email_reply';
|
|
10
|
+
export interface ChannelExecutionContext {
|
|
11
|
+
channel: ChannelKind;
|
|
12
|
+
/** Whether the originating request was cryptographically verified (e.g. Slack signature, OAuth token, API key). */
|
|
13
|
+
verified: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface ExecutionDecision {
|
|
16
|
+
allowed: boolean;
|
|
17
|
+
tier: ApprovalTier;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Gate on WHICH CHANNEL may even request an action of a given tier — distinct
|
|
22
|
+
* from getApprovalTier(), which only decides the tier itself. Call this before
|
|
23
|
+
* requestApproval() at every gated/protected call site.
|
|
24
|
+
*/
|
|
25
|
+
export declare function assertChannelCanExecute(action: string, params: Record<string, unknown> | undefined, context: ChannelExecutionContext): ExecutionDecision;
|
|
26
|
+
/** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
|
|
27
|
+
export declare function currentChannelContext(): ChannelExecutionContext;
|
|
3
28
|
/** Build a human-readable summary of the action for the approval screen */
|
|
4
29
|
export declare function buildApprovalSummary(action: string, params: Record<string, unknown>): string;
|
|
5
30
|
export interface ApprovalResult {
|
|
@@ -16,13 +41,15 @@ export interface ApprovalResult {
|
|
|
16
41
|
* Request human approval for an action. Creates a pending approval token
|
|
17
42
|
* and returns instructions for the agent to wait.
|
|
18
43
|
*/
|
|
19
|
-
export declare function requestApproval(action: string, params: Record<string, unknown>, apiKeyId?: string): Promise<ApprovalResult>;
|
|
44
|
+
export declare function requestApproval(action: string, params: Record<string, unknown>, apiKeyId?: string, channelContext?: ChannelExecutionContext): Promise<ApprovalResult>;
|
|
20
45
|
/**
|
|
21
46
|
* Check if an approval token has been approved.
|
|
22
47
|
* Returns the approval if approved, null if still pending, throws if rejected/expired.
|
|
23
48
|
*/
|
|
24
49
|
export declare function checkApproval(approvalId: string, options?: {
|
|
25
50
|
consume?: boolean;
|
|
51
|
+
action?: string;
|
|
52
|
+
params?: Record<string, unknown>;
|
|
26
53
|
}): Promise<{
|
|
27
54
|
approved: boolean;
|
|
28
55
|
status: string;
|
|
@@ -10,11 +10,13 @@ import { fireWebhookEvent } from './webhook-events.js';
|
|
|
10
10
|
*/
|
|
11
11
|
const GATED_ACTIONS = new Set([
|
|
12
12
|
'launch_campaign', // sends real emails
|
|
13
|
+
'broadcast_to_segment', // creates + activates a real broadcast
|
|
13
14
|
'create_campaign_with_content', // can activate + send in one call
|
|
14
15
|
'enroll_segment', // bulk enrollment
|
|
15
16
|
'import_subscribers', // bulk data ingestion
|
|
16
17
|
'subscribe_webhook', // outbound data channel
|
|
17
18
|
'send_transactional', // sends a real email
|
|
19
|
+
'update_campaign_status', // activation sends real emails (see special case below)
|
|
18
20
|
]);
|
|
19
21
|
const PROTECTED_ACTIONS = new Set([
|
|
20
22
|
'delete_subscriber_data', // GDPR delete — irreversible
|
|
@@ -42,14 +44,75 @@ export function getApprovalTier(action, params) {
|
|
|
42
44
|
return 'autonomous';
|
|
43
45
|
if (action === 'send_transactional' && params?.category === 'test')
|
|
44
46
|
return 'autonomous';
|
|
47
|
+
// update_campaign_status only sends real emails when activating; draft/pause/
|
|
48
|
+
// archive transitions are safe. Keeping this out of GATED_ACTIONS' plain
|
|
49
|
+
// membership check would silently drop the gate for any caller that reaches
|
|
50
|
+
// this action via the generic tier lookup instead of the hand-rolled check
|
|
51
|
+
// in campaigns.ts — so the special case must live here, not just at the call site.
|
|
52
|
+
if (action === 'update_campaign_status' && params?.status !== 'active')
|
|
53
|
+
return 'autonomous';
|
|
45
54
|
if (GATED_ACTIONS.has(action))
|
|
46
55
|
return 'gated';
|
|
47
56
|
return 'autonomous';
|
|
48
57
|
}
|
|
58
|
+
// Channels trusted to originate Tier 1 (gated) actions. Every channel here has
|
|
59
|
+
// either an authenticated session/key or (for slack_command) a verified request
|
|
60
|
+
// signature — see verifySlackSignature in packages/dashboard/lib/slack.ts.
|
|
61
|
+
const TIER1_CHANNELS = new Set([
|
|
62
|
+
'dashboard_session',
|
|
63
|
+
'mcp_oauth_session',
|
|
64
|
+
'mcp_api_key',
|
|
65
|
+
'slack_command',
|
|
66
|
+
]);
|
|
67
|
+
// Channels trusted to originate Tier 2 (protected) actions. Inbound,
|
|
68
|
+
// unattended channels (SMS, email replies, Slack slash commands) are excluded —
|
|
69
|
+
// irreversible actions must originate from an authenticated session/key, even
|
|
70
|
+
// though the final *decision* may still happen via dashboard or a verified
|
|
71
|
+
// Slack interactive component.
|
|
72
|
+
const TIER2_CHANNELS = new Set([
|
|
73
|
+
'dashboard_session',
|
|
74
|
+
'mcp_oauth_session',
|
|
75
|
+
'mcp_api_key',
|
|
76
|
+
]);
|
|
77
|
+
/**
|
|
78
|
+
* Gate on WHICH CHANNEL may even request an action of a given tier — distinct
|
|
79
|
+
* from getApprovalTier(), which only decides the tier itself. Call this before
|
|
80
|
+
* requestApproval() at every gated/protected call site.
|
|
81
|
+
*/
|
|
82
|
+
export function assertChannelCanExecute(action, params, context) {
|
|
83
|
+
const tier = getApprovalTier(action, params);
|
|
84
|
+
if (tier === 'autonomous')
|
|
85
|
+
return { allowed: true, tier };
|
|
86
|
+
if (!context.verified) {
|
|
87
|
+
return {
|
|
88
|
+
allowed: false,
|
|
89
|
+
tier,
|
|
90
|
+
reason: `This action requires human approval (tier: ${tier}), but the request came from an unverified "${context.channel}" channel. Verified channel required.`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const allowedChannels = tier === 'protected' ? TIER2_CHANNELS : TIER1_CHANNELS;
|
|
94
|
+
if (!allowedChannels.has(context.channel)) {
|
|
95
|
+
return {
|
|
96
|
+
allowed: false,
|
|
97
|
+
tier,
|
|
98
|
+
reason: `Channel "${context.channel}" is not permitted to request ${tier}-tier actions.`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { allowed: true, tier };
|
|
102
|
+
}
|
|
103
|
+
/** Convenience: pull channel/verified out of the request-scoped context, defaulting to today's only live caller (mcp_api_key). */
|
|
104
|
+
export function currentChannelContext() {
|
|
105
|
+
const ctx = requestContext();
|
|
106
|
+
return {
|
|
107
|
+
channel: ctx?.channel ?? 'mcp_api_key',
|
|
108
|
+
verified: ctx?.channelVerified ?? true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
49
111
|
/** Build a human-readable summary of the action for the approval screen */
|
|
50
112
|
export function buildApprovalSummary(action, params) {
|
|
51
113
|
const summaries = {
|
|
52
114
|
launch_campaign: (p) => `Launch campaign "${p.name || p.campaign_name || 'unknown'}" — will send emails to real contacts`,
|
|
115
|
+
broadcast_to_segment: (p) => `Broadcast "${p.name || p.subject || 'untitled'}" to segment ${p.segment_id} — will send emails to real contacts`,
|
|
53
116
|
create_campaign_with_content: (p) => `Create + activate campaign "${p.name}" with ${p.steps?.length ?? 0} email(s) and enroll contacts`,
|
|
54
117
|
enroll_segment: (p) => `Enroll all contacts from site into campaign ${p.campaign_id}`,
|
|
55
118
|
import_subscribers: (p) => `Import subscribers (up to ${p.contacts?.length ?? 'unknown'} records)`,
|
|
@@ -64,14 +127,30 @@ export function buildApprovalSummary(action, params) {
|
|
|
64
127
|
const fn = summaries[action];
|
|
65
128
|
return fn ? fn(params) : `Execute ${action} with provided parameters`;
|
|
66
129
|
}
|
|
130
|
+
function normalizeApprovalParams(value) {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
return value.map(normalizeApprovalParams);
|
|
133
|
+
}
|
|
134
|
+
if (value && typeof value === 'object') {
|
|
135
|
+
return Object.fromEntries(Object.entries(value)
|
|
136
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
137
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
138
|
+
.map(([key, entryValue]) => [key, normalizeApprovalParams(entryValue)]));
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function approvalParamsEqual(expected, actual) {
|
|
143
|
+
return JSON.stringify(normalizeApprovalParams(expected)) === JSON.stringify(normalizeApprovalParams(actual));
|
|
144
|
+
}
|
|
67
145
|
/**
|
|
68
146
|
* Request human approval for an action. Creates a pending approval token
|
|
69
147
|
* and returns instructions for the agent to wait.
|
|
70
148
|
*/
|
|
71
|
-
export async function requestApproval(action, params, apiKeyId) {
|
|
149
|
+
export async function requestApproval(action, params, apiKeyId, channelContext) {
|
|
72
150
|
const tier = getApprovalTier(action, params);
|
|
73
151
|
const summary = buildApprovalSummary(action, params);
|
|
74
152
|
const expiresAt = new Date(Date.now() + 30 * 60 * 1000).toISOString();
|
|
153
|
+
const { channel, verified } = channelContext ?? currentChannelContext();
|
|
75
154
|
const { data, error } = await db()
|
|
76
155
|
.from('action_approvals')
|
|
77
156
|
.insert({
|
|
@@ -82,6 +161,8 @@ export async function requestApproval(action, params, apiKeyId) {
|
|
|
82
161
|
params,
|
|
83
162
|
summary,
|
|
84
163
|
expires_at: expiresAt,
|
|
164
|
+
channel,
|
|
165
|
+
channel_verified: verified,
|
|
85
166
|
})
|
|
86
167
|
.select('id')
|
|
87
168
|
.single();
|
|
@@ -111,12 +192,18 @@ export async function requestApproval(action, params, apiKeyId) {
|
|
|
111
192
|
export async function checkApproval(approvalId, options = {}) {
|
|
112
193
|
const { data, error } = await db()
|
|
113
194
|
.from('action_approvals')
|
|
114
|
-
.select('status, decided_by, decided_via, expires_at, used_at')
|
|
195
|
+
.select('action, status, decided_by, decided_via, expires_at, used_at, params')
|
|
115
196
|
.eq('id', approvalId)
|
|
116
197
|
.eq('project_id', getProjectId())
|
|
117
198
|
.maybeSingle();
|
|
118
199
|
if (error || !data)
|
|
119
200
|
return { approved: false, status: 'not_found' };
|
|
201
|
+
if (options.action && data.action !== options.action) {
|
|
202
|
+
return { approved: false, status: 'action_mismatch' };
|
|
203
|
+
}
|
|
204
|
+
if (options.params && !approvalParamsEqual(options.params, data.params)) {
|
|
205
|
+
return { approved: false, status: 'params_mismatch' };
|
|
206
|
+
}
|
|
120
207
|
// Check expiry
|
|
121
208
|
if (data.status === 'pending' && new Date(data.expires_at) < new Date()) {
|
|
122
209
|
await db()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export declare const BRAND_ASSETS_BUCKET = "brand-assets";
|
|
2
|
+
export declare const MAX_ASSET_BYTES: number;
|
|
3
|
+
export type BrandAssetKind = 'logo' | 'image' | 'document' | 'font' | 'avatar' | 'video';
|
|
4
|
+
export declare const BRAND_ASSET_KINDS: BrandAssetKind[];
|
|
5
|
+
export declare function extOf(filename: string): string;
|
|
6
|
+
export declare function resolveMime(filename: string, declared?: string | null): string;
|
|
7
|
+
export declare function isValidKind(value: unknown): value is BrandAssetKind;
|
|
8
|
+
export declare function inferKind(mime: string): BrandAssetKind;
|
|
9
|
+
export declare function refineKindFromName(filename: string, mime: string): BrandAssetKind;
|
|
10
|
+
export declare function resolveKind(inputKind: unknown, filename: string, mimeType: string): BrandAssetKind;
|
|
11
|
+
export type PersistBrandAssetInput = {
|
|
12
|
+
sb: any;
|
|
13
|
+
orgId: string;
|
|
14
|
+
projectId: string;
|
|
15
|
+
bytes: Buffer;
|
|
16
|
+
filename: string;
|
|
17
|
+
mimeType?: string | null;
|
|
18
|
+
kind: BrandAssetKind;
|
|
19
|
+
source: 'uploaded' | 'imported';
|
|
20
|
+
collection?: string | null;
|
|
21
|
+
tags?: string[];
|
|
22
|
+
brandKitId?: string | null;
|
|
23
|
+
};
|
|
24
|
+
export type PersistBrandAssetResult = {
|
|
25
|
+
assetId: string;
|
|
26
|
+
skippedExisting: boolean;
|
|
27
|
+
storagePath: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Uploads bytes to Sendinel's own Supabase Storage + inserts/dedupes the row
|
|
31
|
+
* in email.brand_media_assets, using the MCP server's own adminDb() client —
|
|
32
|
+
* no round-trip through the dashboard's v1 API, no separate SENDINEL_API_KEY.
|
|
33
|
+
* Video (R2-backed in the dashboard) is not supported via this path.
|
|
34
|
+
*/
|
|
35
|
+
export declare function persistBrandAsset(input: PersistBrandAssetInput): Promise<PersistBrandAssetResult>;
|