@sendinel/mcp-server 1.0.4 → 1.0.6

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.
Files changed (60) hide show
  1. package/build/audit.d.ts +1 -0
  2. package/build/audit.js +9 -3
  3. package/build/auth.d.ts +10 -0
  4. package/build/auth.js +82 -9
  5. package/build/db.d.ts +12 -0
  6. package/build/db.js +37 -2
  7. package/build/index.js +38 -12
  8. package/build/lib/action-approvals.d.ts +28 -1
  9. package/build/lib/action-approvals.js +89 -2
  10. package/build/lib/brand-asset-persist.d.ts +35 -0
  11. package/build/lib/brand-asset-persist.js +161 -0
  12. package/build/lib/brand-asset-usage.d.ts +13 -0
  13. package/build/lib/brand-asset-usage.js +46 -0
  14. package/build/lib/email-provider.d.ts +19 -0
  15. package/build/lib/email-provider.js +281 -0
  16. package/build/lib/ssrf-pinned-fetch.d.ts +16 -0
  17. package/build/lib/ssrf-pinned-fetch.js +101 -0
  18. package/build/lib/survey-link-resolver.d.ts +6 -0
  19. package/build/lib/survey-link-resolver.js +23 -0
  20. package/build/lib/svg-sanitize.d.ts +2 -0
  21. package/build/lib/svg-sanitize.js +110 -0
  22. package/build/lib/webhook-events.d.ts +1 -1
  23. package/build/scoped-token.d.ts +11 -0
  24. package/build/scoped-token.js +59 -0
  25. package/build/server.js +30 -9
  26. package/build/tools/ab-testing.js +28 -0
  27. package/build/tools/advisor.js +52 -2
  28. package/build/tools/ai-work.d.ts +2 -0
  29. package/build/tools/ai-work.js +460 -0
  30. package/build/tools/analytics.d.ts +1 -0
  31. package/build/tools/analytics.js +203 -0
  32. package/build/tools/approvals.js +15 -1
  33. package/build/tools/automations.js +288 -65
  34. package/build/tools/brand-kit.d.ts +2 -0
  35. package/build/tools/brand-kit.js +277 -0
  36. package/build/tools/campaigns.js +900 -69
  37. package/build/tools/compound.js +28 -0
  38. package/build/tools/contacts.js +338 -17
  39. package/build/tools/content.js +266 -1
  40. package/build/tools/data-proposals.js +21 -0
  41. package/build/tools/data.js +140 -8
  42. package/build/tools/delivery-ops.js +84 -0
  43. package/build/tools/drafts.js +64 -1
  44. package/build/tools/forms.js +42 -9
  45. package/build/tools/gdpr.js +22 -3
  46. package/build/tools/newsletters.d.ts +2 -0
  47. package/build/tools/newsletters.js +583 -0
  48. package/build/tools/org.js +297 -3
  49. package/build/tools/segments.js +45 -3
  50. package/build/tools/short-links.d.ts +2 -0
  51. package/build/tools/short-links.js +157 -0
  52. package/build/tools/sites.js +35 -0
  53. package/build/tools/sms.js +49 -0
  54. package/build/tools/social-posts.js +81 -5
  55. package/build/tools/surveys.d.ts +2 -0
  56. package/build/tools/surveys.js +222 -0
  57. package/build/tools/templates.js +78 -2
  58. package/build/tools/warmup.js +28 -0
  59. package/build/tools/webhooks.js +29 -0
  60. package/package.json +9 -3
package/build/audit.d.ts CHANGED
@@ -18,4 +18,5 @@ export declare function logAudit(opts: {
18
18
  resourceType: string;
19
19
  resourceId?: string;
20
20
  details?: Record<string, unknown>;
21
+ channel?: string;
21
22
  }): Promise<void>;
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: opts.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 function resolveToolGroupsForPlan(plan, requested) {
5
- if (requested && requested.length > 0) {
6
- return requested.filter((group) => ALL_TOOL_GROUPS.includes(group));
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
- async function getProjectPlan(projectId) {
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 data?.plan ?? 'free';
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 getProjectPlan(projectId);
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: 'standard',
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 getProjectPlan(row.project_id);
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 ? { projectId: ctx.projectId, approvalMode: ctx.approvalMode } : undefined;
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
- return _requestStore.getStore()?.client ?? _processClient ?? adminDb();
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, resolveProjectClient } from './byod-client.js';
13
- import { serviceKeyInfo, validateKey } from './auth.js';
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
- let info = await validateKey(key);
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
- const projectClient = await resolveProjectClient(info.projectId);
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
- }, { projectId: info.projectId, approvalMode: info.approvalMode });
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>;