@sendinel/mcp-server 1.0.0

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 (90) hide show
  1. package/README.md +113 -0
  2. package/build/anthropic-model.d.ts +5 -0
  3. package/build/anthropic-model.js +6 -0
  4. package/build/audit.d.ts +21 -0
  5. package/build/audit.js +38 -0
  6. package/build/auth.d.ts +10 -0
  7. package/build/auth.js +57 -0
  8. package/build/byod-client.d.ts +30 -0
  9. package/build/byod-client.js +95 -0
  10. package/build/crypto.d.ts +2 -0
  11. package/build/crypto.js +27 -0
  12. package/build/db.d.ts +38 -0
  13. package/build/db.js +70 -0
  14. package/build/index.d.ts +1 -0
  15. package/build/index.js +97 -0
  16. package/build/lib/action-approvals.d.ts +30 -0
  17. package/build/lib/action-approvals.js +154 -0
  18. package/build/lib/schedule.d.ts +36 -0
  19. package/build/lib/schedule.js +63 -0
  20. package/build/lib/social-engine/archetypes.d.ts +27 -0
  21. package/build/lib/social-engine/archetypes.js +160 -0
  22. package/build/lib/social-engine/index.d.ts +5 -0
  23. package/build/lib/social-engine/index.js +6 -0
  24. package/build/lib/social-engine/parse.d.ts +7 -0
  25. package/build/lib/social-engine/parse.js +83 -0
  26. package/build/lib/social-engine/platform-constraints.d.ts +27 -0
  27. package/build/lib/social-engine/platform-constraints.js +70 -0
  28. package/build/lib/social-engine/prompt-builder.d.ts +13 -0
  29. package/build/lib/social-engine/prompt-builder.js +80 -0
  30. package/build/lib/social-engine/types.d.ts +60 -0
  31. package/build/lib/social-engine/types.js +19 -0
  32. package/build/lib/url-validation.d.ts +3 -0
  33. package/build/lib/url-validation.js +51 -0
  34. package/build/lib/voice.d.ts +32 -0
  35. package/build/lib/voice.js +140 -0
  36. package/build/lib/webhook-events.d.ts +15 -0
  37. package/build/lib/webhook-events.js +120 -0
  38. package/build/plan-limits.d.ts +8 -0
  39. package/build/plan-limits.js +9 -0
  40. package/build/project.d.ts +1 -0
  41. package/build/project.js +9 -0
  42. package/build/server.d.ts +18 -0
  43. package/build/server.js +235 -0
  44. package/build/tools/ab-testing.d.ts +2 -0
  45. package/build/tools/ab-testing.js +204 -0
  46. package/build/tools/advisor.d.ts +23 -0
  47. package/build/tools/advisor.js +762 -0
  48. package/build/tools/analytics.d.ts +33 -0
  49. package/build/tools/analytics.js +1105 -0
  50. package/build/tools/approvals.d.ts +2 -0
  51. package/build/tools/approvals.js +32 -0
  52. package/build/tools/automations.d.ts +2 -0
  53. package/build/tools/automations.js +344 -0
  54. package/build/tools/campaigns.d.ts +2 -0
  55. package/build/tools/campaigns.js +1335 -0
  56. package/build/tools/compound.d.ts +2 -0
  57. package/build/tools/compound.js +312 -0
  58. package/build/tools/contacts.d.ts +2 -0
  59. package/build/tools/contacts.js +1483 -0
  60. package/build/tools/content.d.ts +2 -0
  61. package/build/tools/content.js +68 -0
  62. package/build/tools/data-proposals.d.ts +2 -0
  63. package/build/tools/data-proposals.js +155 -0
  64. package/build/tools/data.d.ts +2 -0
  65. package/build/tools/data.js +707 -0
  66. package/build/tools/delivery-ops.d.ts +2 -0
  67. package/build/tools/delivery-ops.js +387 -0
  68. package/build/tools/drafts.d.ts +2 -0
  69. package/build/tools/drafts.js +204 -0
  70. package/build/tools/forms.d.ts +2 -0
  71. package/build/tools/forms.js +46 -0
  72. package/build/tools/gdpr.d.ts +2 -0
  73. package/build/tools/gdpr.js +61 -0
  74. package/build/tools/org.d.ts +2 -0
  75. package/build/tools/org.js +71 -0
  76. package/build/tools/segments.d.ts +2 -0
  77. package/build/tools/segments.js +384 -0
  78. package/build/tools/sites.d.ts +2 -0
  79. package/build/tools/sites.js +182 -0
  80. package/build/tools/sms.d.ts +2 -0
  81. package/build/tools/sms.js +489 -0
  82. package/build/tools/social-posts.d.ts +2 -0
  83. package/build/tools/social-posts.js +380 -0
  84. package/build/tools/templates.d.ts +2 -0
  85. package/build/tools/templates.js +282 -0
  86. package/build/tools/warmup.d.ts +2 -0
  87. package/build/tools/warmup.js +57 -0
  88. package/build/tools/webhooks.d.ts +2 -0
  89. package/build/tools/webhooks.js +127 -0
  90. package/package.json +63 -0
@@ -0,0 +1,13 @@
1
+ import type { SocialArchetype } from "./archetypes.js";
2
+ import type { SocialBrief, SocialPostPlatform } from "./types.js";
3
+ export interface SocialPromptInput {
4
+ brief: SocialBrief;
5
+ platform: SocialPostPlatform;
6
+ archetype: SocialArchetype;
7
+ /** Output of buildBrandContext() — org mission/audience/differentiators. */
8
+ brandContext: string;
9
+ /** Output of buildVoicePrompt() — tone, sign-off, anti-patterns. */
10
+ voiceRules: string;
11
+ }
12
+ export declare function buildSocialSystemPrompt(input: SocialPromptInput): string;
13
+ export declare function buildSocialUserPrompt(input: SocialPromptInput): string;
@@ -0,0 +1,80 @@
1
+ // Multi-layer social prompt builder (#1360) — mirrors Synchronex's Luca
2
+ // pipeline: per-role content rules, a short-form voice guide, platform-native
3
+ // formatting, and the [BRAND CONTEXT]/[VOICE RULES] blocks Sendinel already
4
+ // derives from the Brand Brain — composed into one system prompt instead of a
5
+ // single "write a post about X" line. Pure string assembly: the caller owns
6
+ // the model call, so this whole layer is unit-testable for free.
7
+ // Mirror of packages/dashboard/lib/social-engine/prompt-builder.ts.
8
+ import { buildPlatformRuleBlock, getPlatformConstraints } from "./platform-constraints.js";
9
+ // Hard rules per structural role — the depth that separates creator-grade
10
+ // copy from generic marketing. Mirrors Luca's per-role rules.
11
+ const ROLE_RULES = {
12
+ hook: "HOOK: 12 words or fewer. Must work standing alone — assume nothing after it gets read. No questions unless the archetype's formula is a question. Never start with 'I'm excited' or any announcement filler.",
13
+ pain: "PAIN: name the specific annoyance in the reader's words, not category language. 'Your welcome email goes out 40 minutes late' beats 'engagement challenges'.",
14
+ proof: "PROOF: exactly one concrete number or named outcome. Never vague comparatives — 'faster', 'better', 'easier' are banned in this role.",
15
+ solution: "SOLUTION: what changes for the reader, not what the product has. One sentence of mechanism is allowed; zero feature lists.",
16
+ story: "STORY: one scene, told in sequence, with one unglamorous concrete detail that proves you were there. No moral until the end.",
17
+ steps: "STEPS: numbered. Each step starts with a verb and is doable today. No step over two lines. 3–5 steps total.",
18
+ question: "QUESTION: end on one genuine question the reader can answer from experience. Not 'thoughts?' — ask about THEIR practice.",
19
+ cta: "CTA: one action, stated plainly. No 'don't miss out', no urgency theater. A soft CTA ('worth a look if X') is fine where the platform culture expects it.",
20
+ };
21
+ const SHORT_FORM_VOICE_GUIDE = `[SHORT-FORM VOICE GUIDE]
22
+ - Write like a person typing to a smart friend, not a brand publishing.
23
+ - Short sentences. Vary rhythm. One idea per line where line breaks are allowed.
24
+ - Cut adjectives; prefer a concrete noun or number.
25
+ - Banned everywhere: "game-changer", "unlock", "elevate", "revolutionize", "seamless", "supercharge", "delve", "in today's fast-paced world".
26
+ - No corporate transitions ("furthermore", "additionally", "moreover").
27
+ - Specifics beat claims: name the tool, the number, the day of the week.`;
28
+ export function buildSocialSystemPrompt(input) {
29
+ const { archetype, platform, brief } = input;
30
+ const roleBlock = archetype.structure.map((role) => `- ${ROLE_RULES[role]}`).join("\n");
31
+ const mediaInstruction = brief.format === "carousel"
32
+ ? `Also return a "media_plan": { "kind": "carousel", "frames": [4–7 frames of { "order", "headline" (max 8 words), "support_text" (max 20 words), "visual_direction" (one concrete art note) }] }. Frame 1 is the hook frame.`
33
+ : brief.format === "single_image"
34
+ ? `Also return a "media_plan": { "kind": "single_image", "image_direction": one concrete art-direction sentence (subject, composition, mood — no abstract concepts) }.`
35
+ : brief.format === "video_script"
36
+ ? `Also return a "media_plan": { "kind": "video_script", "image_direction": one sentence describing the opening shot }.`
37
+ : "";
38
+ return `You are a top-tier ${platform} creator writing for one specific brand. You write platform-native posts that real creators would publish — never marketing copy wearing a costume.
39
+
40
+ ${input.brandContext}
41
+
42
+ ${input.voiceRules}
43
+
44
+ [ARCHETYPE: ${archetype.name}]
45
+ Hook formula to adapt (never copy verbatim): "${archetype.hook_formula}"
46
+ Why it works: ${archetype.neurodesign_trigger}.
47
+ Write in the spirit of: ${archetype.reference_creators.join(", ")}.
48
+ ${archetype.voice_notes}
49
+
50
+ [STRUCTURE — follow these roles in order]
51
+ ${roleBlock}
52
+
53
+ ${SHORT_FORM_VOICE_GUIDE}
54
+
55
+ [PLATFORM RULES]
56
+ ${buildPlatformRuleBlock(platform)}
57
+
58
+ [OUTPUT]
59
+ Return ONLY valid JSON, no markdown fences:
60
+ {
61
+ "variants": [
62
+ { "label": "A", "angle": "<one line: the angle this take uses>", "hook": "...", "body": "...", "cta": "...", "hashtags": ["..."] },
63
+ { "label": "B", ... },
64
+ { "label": "C", ... }
65
+ ]
66
+ }
67
+ ${mediaInstruction}
68
+ The three variants must take genuinely different angles (e.g. outcome-led vs. pain-led vs. story-led) — not the same post reworded. "body" may contain \\n for line breaks where the platform allows them. ${archetype.structure.includes("cta") ? "" : `This archetype ends without a CTA — return "cta": "".`}`;
69
+ }
70
+ export function buildSocialUserPrompt(input) {
71
+ const { brief, platform } = input;
72
+ const constraints = getPlatformConstraints(platform);
73
+ return `Goal of this post: ${brief.goal}
74
+ ${brief.audience ? `Audience override for this post: ${brief.audience}\n` : ""}${brief.style ? `Style note from the user: ${brief.style}\n` : ""}Format: ${brief.format}
75
+
76
+ Source material (adapt, never excerpt):
77
+ ${brief.source_content.slice(0, 2000)}
78
+
79
+ Write the ${platform} post now — full caption must land under ${constraints.maxChars} characters including hashtags. Return the JSON only.`;
80
+ }
@@ -0,0 +1,60 @@
1
+ export declare const SOCIAL_POST_PLATFORMS: readonly ["instagram", "linkedin", "twitter", "x", "facebook", "tiktok", "youtube", "pinterest", "threads", "reddit", "bluesky", "google_business"];
2
+ export type SocialPostPlatform = typeof SOCIAL_POST_PLATFORMS[number];
3
+ export type SocialFormat = "text" | "single_image" | "carousel" | "video_script";
4
+ export interface SocialBrief {
5
+ /** What this post should achieve, in plain language. */
6
+ goal: string;
7
+ /** Source material — email content, notes, announcement copy. */
8
+ source_content: string;
9
+ /** Optional audience override; Brand Brain audience is the default. */
10
+ audience?: string;
11
+ format: SocialFormat;
12
+ platforms: SocialPostPlatform[];
13
+ /** Explicit archetype override; otherwise selected from the brief. */
14
+ archetype_id?: string;
15
+ /** Optional style note from the user ("more playful", "no emojis"). */
16
+ style?: string;
17
+ }
18
+ export type VariantLabel = "A" | "B" | "C";
19
+ export interface CarouselFrame {
20
+ order: number;
21
+ headline: string;
22
+ support_text: string;
23
+ visual_direction: string;
24
+ }
25
+ export interface MediaPlan {
26
+ kind: Exclude<SocialFormat, "text">;
27
+ /** Art direction for a single image / video opener. */
28
+ image_direction?: string;
29
+ /** Carousel frames, first frame is the hook frame. */
30
+ frames?: CarouselFrame[];
31
+ }
32
+ export interface SocialVariant {
33
+ label: VariantLabel;
34
+ /** The distinct angle this variant takes (one line, for the picker UI). */
35
+ angle: string;
36
+ hook: string;
37
+ body: string;
38
+ cta: string;
39
+ hashtags: string[];
40
+ /** Assembled, platform-formatted caption — what actually gets published. */
41
+ caption: string;
42
+ }
43
+ export interface SocialPostIR {
44
+ platform: SocialPostPlatform;
45
+ archetype_id: string;
46
+ variants: SocialVariant[];
47
+ media_plan?: MediaPlan;
48
+ /** Constraint findings from gen-time validation (empty = clean). */
49
+ warnings: string[];
50
+ }
51
+ /** Persisted on social_posts.generation — lets a platform segment be
52
+ * regenerated independently without touching the others. Captions only;
53
+ * rendered video/image is a separate concern. */
54
+ export interface SocialGenerationRecord {
55
+ brief: SocialBrief;
56
+ archetype_id: string;
57
+ /** platform -> IR; keys regenerate independently. */
58
+ posts: Partial<Record<SocialPostPlatform, SocialPostIR>>;
59
+ generated_at: string;
60
+ }
@@ -0,0 +1,19 @@
1
+ // Social engine IR — the structured output of social generation (#1360).
2
+ // Mirror of packages/dashboard/lib/social-engine/types.ts for the MCP server
3
+ // package. The dashboard sources SocialPostPlatform from @/lib/social-posts;
4
+ // the MCP package has no path alias, so the platform list lives here and every
5
+ // engine file imports it from "./types.js".
6
+ export const SOCIAL_POST_PLATFORMS = [
7
+ "instagram",
8
+ "linkedin",
9
+ "twitter",
10
+ "x",
11
+ "facebook",
12
+ "tiktok",
13
+ "youtube",
14
+ "pinterest",
15
+ "threads",
16
+ "reddit",
17
+ "bluesky",
18
+ "google_business",
19
+ ];
@@ -0,0 +1,3 @@
1
+ export declare function isSafeRedirectUrl(url: unknown): url is string;
2
+ export declare function isAllowedHttpsHostUrl(url: unknown, allowedDomains: readonly string[]): url is string;
3
+ export declare function normalizeActiveCampaignAccountUrl(accountUrl: string): string;
@@ -0,0 +1,51 @@
1
+ export function isSafeRedirectUrl(url) {
2
+ if (typeof url !== 'string' || !url)
3
+ return false;
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(url);
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ if (parsed.protocol !== 'https:')
12
+ return false;
13
+ if (parsed.username || parsed.password)
14
+ return false;
15
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
16
+ if (host === 'localhost' ||
17
+ host.endsWith('.localhost') ||
18
+ host === '0.0.0.0' ||
19
+ host === '::1' ||
20
+ /^127\./.test(host) ||
21
+ /^10\./.test(host) ||
22
+ /^169\.254\./.test(host) ||
23
+ /^192\.168\./.test(host) ||
24
+ /^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
25
+ /^fc[0-9a-f]{0,2}:/i.test(host) ||
26
+ /^fd[0-9a-f]{0,2}:/i.test(host) ||
27
+ /^fe80:/i.test(host)) {
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+ export function isAllowedHttpsHostUrl(url, allowedDomains) {
33
+ if (!isSafeRedirectUrl(url))
34
+ return false;
35
+ const parsed = new URL(url);
36
+ const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
37
+ return allowedDomains.some((domain) => {
38
+ const normalized = domain.toLowerCase().replace(/^\.+/, '');
39
+ return host === normalized || host.endsWith(`.${normalized}`);
40
+ });
41
+ }
42
+ export function normalizeActiveCampaignAccountUrl(accountUrl) {
43
+ const raw = String(accountUrl ?? '').trim().replace(/^https?:\/\//, '').replace(/\/+$/, '');
44
+ if (!raw)
45
+ throw new Error('ActiveCampaign account URL is required');
46
+ const base = raw.includes('/api/3') ? `https://${raw}` : `https://${raw}/api/3`;
47
+ if (!isAllowedHttpsHostUrl(base, ['activehosted.com', 'api-ac.com'])) {
48
+ throw new Error('ActiveCampaign account URL is not allowed');
49
+ }
50
+ return base;
51
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Brand voice prompt builder for AI email generation.
3
+ * Mirror of packages/dashboard/lib/voice.ts for the MCP server package.
4
+ */
5
+ interface BrandVoiceConfig {
6
+ tone?: string;
7
+ greeting_style?: string;
8
+ sign_off?: string;
9
+ rules?: string[];
10
+ anti_patterns?: string[];
11
+ max_paragraphs?: number;
12
+ }
13
+ export interface BrandSettings {
14
+ mission?: string;
15
+ audience?: string;
16
+ tone_words?: string[];
17
+ forbidden_phrases?: string[];
18
+ differentiators?: string;
19
+ default_signoff_name?: string;
20
+ ps_line?: string;
21
+ }
22
+ export declare function buildVoicePrompt(siteVoice: Record<string, unknown> | null | undefined, projectSettings?: BrandSettings | null): string;
23
+ export declare function buildEmailSystemPrompt(brandContext: string, voicePrompt: string): string;
24
+ export declare const KNOWN_VARIABLES: Set<string>;
25
+ export declare const STOCK_PHRASES: string[];
26
+ export interface ValidationResult {
27
+ valid: boolean;
28
+ errors: string[];
29
+ warnings: string[];
30
+ }
31
+ export declare function validateTemplate(html: string, voiceConfig?: BrandVoiceConfig | null): ValidationResult;
32
+ export {};
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Brand voice prompt builder for AI email generation.
3
+ * Mirror of packages/dashboard/lib/voice.ts for the MCP server package.
4
+ */
5
+ const DEFAULT_VOICE = {
6
+ tone: "Direct. Warm. Zero corporate. Write like you're sending a quick email to someone you respect but haven't met in person yet.",
7
+ greeting_style: 'Hey {{first_name}},',
8
+ sign_off: "— Kevin",
9
+ rules: [
10
+ "First name always. Never \"Dear Valued User\" or \"Hi there.\"",
11
+ "Short sentences. If a paragraph hits 3 sentences, break it.",
12
+ "One CTA per email. One. Don't make them choose.",
13
+ "Plain text wins. HTML is just for the button.",
14
+ "No exclamation marks unless something genuinely warrants one.",
15
+ 'No "just checking in" or "hope this finds you well."',
16
+ "Say what it is, why it matters, what to do. Done.",
17
+ ],
18
+ anti_patterns: [
19
+ "emojis in body text",
20
+ "stock phrases",
21
+ "walls of text",
22
+ "multiple asks or CTAs",
23
+ "em dashes (—)",
24
+ ],
25
+ max_paragraphs: 5,
26
+ };
27
+ export function buildVoicePrompt(siteVoice, projectSettings) {
28
+ const v = { ...DEFAULT_VOICE };
29
+ if (projectSettings) {
30
+ if (projectSettings.tone_words?.length) {
31
+ v.tone = `${projectSettings.tone_words.join('. ')}. Write like you're emailing someone you respect.`;
32
+ }
33
+ if (projectSettings.default_signoff_name) {
34
+ v.sign_off = `- ${projectSettings.default_signoff_name}`;
35
+ }
36
+ if (projectSettings.forbidden_phrases?.length) {
37
+ v.anti_patterns = [...v.anti_patterns, ...projectSettings.forbidden_phrases];
38
+ }
39
+ }
40
+ if (siteVoice && Object.keys(siteVoice).length > 0) {
41
+ const sv = siteVoice;
42
+ if (sv.tone)
43
+ v.tone = sv.tone;
44
+ if (sv.greeting_style)
45
+ v.greeting_style = sv.greeting_style;
46
+ if (sv.sign_off)
47
+ v.sign_off = sv.sign_off;
48
+ if (sv.rules?.length)
49
+ v.rules = sv.rules;
50
+ if (sv.anti_patterns?.length)
51
+ v.anti_patterns = sv.anti_patterns;
52
+ if (sv.max_paragraphs)
53
+ v.max_paragraphs = sv.max_paragraphs;
54
+ }
55
+ const lines = [
56
+ "BRAND VOICE RULES (you MUST follow these):",
57
+ `- Tone: ${v.tone}`,
58
+ `- Greeting: "${v.greeting_style}" — never "Dear" or formal salutations`,
59
+ `- Sign off: "${v.sign_off}" (no title, no logo, no social links)`,
60
+ ...v.rules.map((r) => `- ${r}`),
61
+ `- Maximum ${v.max_paragraphs} short paragraphs.`,
62
+ `- NEVER use: ${v.anti_patterns.join(", ")}.`,
63
+ ];
64
+ return lines.join("\n");
65
+ }
66
+ export function buildEmailSystemPrompt(brandContext, voicePrompt) {
67
+ return `You are an expert email copywriter writing for a specific brand. ${brandContext}.
68
+
69
+ ${voicePrompt}
70
+
71
+ Return a JSON object with keys: "subject" (string), "body_html" (string, clean HTML email with inline styles), "body_text" (string, plain text version). Return ONLY valid JSON, no explanation. Use {{first_name}} as the personalization variable for the recipient's name.`;
72
+ }
73
+ export const KNOWN_VARIABLES = new Set([
74
+ "first_name",
75
+ "last_name",
76
+ "email",
77
+ "unsubscribe_url",
78
+ ]);
79
+ export const STOCK_PHRASES = [
80
+ "just checking in",
81
+ "hope this finds you well",
82
+ "hope you're doing well",
83
+ "dear valued",
84
+ "dear customer",
85
+ "dear user",
86
+ "hi there",
87
+ "to whom it may concern",
88
+ "we are pleased to",
89
+ "we are excited to",
90
+ "don't hesitate to",
91
+ "please do not hesitate",
92
+ ];
93
+ export function validateTemplate(html, voiceConfig) {
94
+ const errors = [];
95
+ const warnings = [];
96
+ const varPattern = /\{\{(\w+)\}\}/g;
97
+ let match;
98
+ while ((match = varPattern.exec(html)) !== null) {
99
+ if (!KNOWN_VARIABLES.has(match[1])) {
100
+ errors.push(`Unknown variable: {{${match[1]}}}`);
101
+ }
102
+ }
103
+ const unclosed = html.match(/\{\{[^}]*$/gm);
104
+ if (unclosed) {
105
+ errors.push("Unclosed template variable bracket detected");
106
+ }
107
+ const textContent = html.replace(/<[^>]+>/g, " ").toLowerCase();
108
+ const exclamations = (textContent.match(/!/g) || []).length;
109
+ if (exclamations > 1) {
110
+ warnings.push(`${exclamations} exclamation marks found — voice guide limits to max 1`);
111
+ }
112
+ for (const phrase of STOCK_PHRASES) {
113
+ if (textContent.includes(phrase)) {
114
+ warnings.push(`Stock phrase detected: "${phrase}"`);
115
+ }
116
+ }
117
+ const hrefPattern = /href=["']([^"'#]+)["']/gi;
118
+ const hrefs = new Set();
119
+ let hrefMatch;
120
+ while ((hrefMatch = hrefPattern.exec(html)) !== null) {
121
+ const url = hrefMatch[1];
122
+ if (!url.startsWith("mailto:") &&
123
+ !url.includes("unsubscribe") &&
124
+ !url.includes("preferences")) {
125
+ hrefs.add(url);
126
+ }
127
+ }
128
+ if (hrefs.size > 2) {
129
+ warnings.push(`${hrefs.size} distinct CTA links found — voice guide recommends one CTA per email`);
130
+ }
131
+ const signOff = voiceConfig?.sign_off || DEFAULT_VOICE.sign_off;
132
+ if (signOff && !html.includes(signOff) && !html.includes(signOff.replace("—", "–"))) {
133
+ warnings.push(`Missing expected sign-off: "${signOff}"`);
134
+ }
135
+ return {
136
+ valid: errors.length === 0,
137
+ errors,
138
+ warnings,
139
+ };
140
+ }
@@ -0,0 +1,15 @@
1
+ export type WebhookEvent = 'campaign.activated' | 'campaign.completed' | 'campaign.paused' | 'email.delivered' | 'email.bounced' | 'email.complained' | 'bounce_rate.warning' | 'domain.dns_lost' | 'approval.requested' | 'approval.decided' | 'social.post.created' | 'social.post.updated' | 'social.post.scheduled' | 'social.post.published' | 'social.post.failed' | 'social.post.archived';
2
+ /**
3
+ * Fire a webhook event to all active subscriptions matching the event type.
4
+ * Non-blocking — failures are logged to webhook_deliveries for retry.
5
+ *
6
+ * Uses the project's own DB for subscription lookup (BYOD-aware),
7
+ * and fire-and-forget HTTP delivery.
8
+ */
9
+ export declare function fireWebhookEvent(event: WebhookEvent, data: Record<string, unknown>, projectId?: string): Promise<number>;
10
+ export declare function validateWebhookUrl(url: string): {
11
+ valid: boolean;
12
+ reason?: string;
13
+ };
14
+ /** Generate a random webhook signing secret */
15
+ export declare function generateWebhookSecret(): string;
@@ -0,0 +1,120 @@
1
+ import { createHmac, randomUUID } from 'node:crypto';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ /**
5
+ * Fire a webhook event to all active subscriptions matching the event type.
6
+ * Non-blocking — failures are logged to webhook_deliveries for retry.
7
+ *
8
+ * Uses the project's own DB for subscription lookup (BYOD-aware),
9
+ * and fire-and-forget HTTP delivery.
10
+ */
11
+ export async function fireWebhookEvent(event, data, projectId) {
12
+ const pid = projectId ?? getProjectId();
13
+ // Look up active subscriptions matching this event
14
+ const { data: subs, error } = await db()
15
+ .from('webhook_subscriptions')
16
+ .select('id, url, secret')
17
+ .eq('project_id', pid)
18
+ .eq('active', true)
19
+ .contains('events', [event]);
20
+ if (error || !subs?.length)
21
+ return 0;
22
+ const timestamp = new Date().toISOString();
23
+ const payload = {
24
+ ...data,
25
+ data,
26
+ event,
27
+ event_type: event,
28
+ id: randomUUID(),
29
+ occurred_at: timestamp,
30
+ project_id: pid,
31
+ timestamp,
32
+ };
33
+ const body = JSON.stringify(payload);
34
+ let delivered = 0;
35
+ for (const sub of subs) {
36
+ try {
37
+ // HMAC-SHA256 signature (same pattern as Svix/Stripe webhook signing)
38
+ const signature = createHmac('sha256', sub.secret)
39
+ .update(body)
40
+ .digest('hex');
41
+ const res = await fetch(sub.url, {
42
+ method: 'POST',
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'X-Sendinel-Event': event,
46
+ 'X-Sendinel-Signature': `sha256=${signature}`,
47
+ 'X-Sendinel-Timestamp': payload.timestamp,
48
+ },
49
+ body,
50
+ signal: AbortSignal.timeout(10_000), // 10s timeout
51
+ });
52
+ // Log delivery
53
+ await db().from('webhook_deliveries').insert({
54
+ subscription_id: sub.id,
55
+ event,
56
+ payload,
57
+ status_code: res.status,
58
+ attempts: 1,
59
+ exhausted: res.ok,
60
+ });
61
+ if (res.ok)
62
+ delivered++;
63
+ }
64
+ catch (err) {
65
+ // Log failed delivery for retry
66
+ await db().from('webhook_deliveries').insert({
67
+ subscription_id: sub.id,
68
+ event,
69
+ payload,
70
+ status_code: null,
71
+ attempts: 1,
72
+ next_retry_at: new Date(Date.now() + 60_000).toISOString(), // retry in 1 min
73
+ }).catch(() => { }); // don't fail the whole loop
74
+ }
75
+ }
76
+ return delivered;
77
+ }
78
+ // URL validation to prevent SSRF
79
+ const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
80
+ const PRIVATE_RANGES = [
81
+ /^10\./,
82
+ /^172\.(1[6-9]|2\d|3[0-1])\./,
83
+ /^192\.168\./,
84
+ /^169\.254\./,
85
+ /^fc00:/i,
86
+ /^fd/i,
87
+ ];
88
+ export function validateWebhookUrl(url) {
89
+ try {
90
+ const parsed = new URL(url);
91
+ // Must be HTTPS (except in development)
92
+ if (parsed.protocol !== 'https:' && process.env.NODE_ENV !== 'development') {
93
+ return { valid: false, reason: 'URL must use HTTPS' };
94
+ }
95
+ // Block private/local hosts
96
+ if (BLOCKED_HOSTS.has(parsed.hostname)) {
97
+ return { valid: false, reason: 'URL cannot point to localhost or loopback addresses' };
98
+ }
99
+ // Block private IP ranges
100
+ for (const range of PRIVATE_RANGES) {
101
+ if (range.test(parsed.hostname)) {
102
+ return { valid: false, reason: 'URL cannot point to private IP ranges' };
103
+ }
104
+ }
105
+ // Block file:// and other schemes
106
+ if (!['https:', 'http:'].includes(parsed.protocol)) {
107
+ return { valid: false, reason: 'URL must use HTTP or HTTPS protocol' };
108
+ }
109
+ return { valid: true };
110
+ }
111
+ catch {
112
+ return { valid: false, reason: 'Invalid URL format' };
113
+ }
114
+ }
115
+ /** Generate a random webhook signing secret */
116
+ export function generateWebhookSecret() {
117
+ const bytes = new Uint8Array(32);
118
+ crypto.getRandomValues(bytes);
119
+ return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
120
+ }
@@ -0,0 +1,8 @@
1
+ interface LimitCheck {
2
+ allowed: boolean;
3
+ current: number;
4
+ limit: number;
5
+ resource: string;
6
+ }
7
+ export declare function checkPlanLimit(resource: 'contacts' | 'monthly_sends' | 'active_campaigns' | 'sites' | 'ai_drafts'): Promise<LimitCheck>;
8
+ export {};
@@ -0,0 +1,9 @@
1
+ import { adminDb } from './db.js';
2
+ import { getProjectId } from './project.js';
3
+ export async function checkPlanLimit(resource) {
4
+ const { data, error } = await adminDb()
5
+ .rpc('check_plan_limit', { p_project_id: getProjectId(), p_resource: resource });
6
+ if (error)
7
+ throw new Error(`Plan limit check failed: ${error.message}`);
8
+ return data;
9
+ }
@@ -0,0 +1 @@
1
+ export declare function getProjectId(): string;
@@ -0,0 +1,9 @@
1
+ import { requestContext } from './db.js';
2
+ export function getProjectId() {
3
+ // HTTP transport: request-scoped (concurrent multi-tenant safe).
4
+ // stdio transport: single project per process, set in env at startup.
5
+ const id = requestContext()?.projectId ?? process.env.SENDINEL_PROJECT_ID;
6
+ if (!id)
7
+ throw new Error("SENDINEL_PROJECT_ID env var is required");
8
+ return id;
9
+ }
@@ -0,0 +1,18 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export interface ToolRegistrationOptions {
3
+ scopes?: string[];
4
+ toolGroups?: string[];
5
+ }
6
+ declare const GROUP_REGISTRY: Record<string, (server: McpServer) => void>;
7
+ export declare const ALL_TOOL_GROUPS: string[];
8
+ export type ToolGroup = keyof typeof GROUP_REGISTRY;
9
+ export type ToolGroupMap = Record<string, string>;
10
+ export declare function getRequiredScope(name: string): string;
11
+ /**
12
+ * Collects tool-name → group metadata from the same group registry used by
13
+ * registerAllTools(). Scope filtering and definition scanning are applied
14
+ * through the same proxy path as real MCP registration.
15
+ */
16
+ export declare function getRegisteredToolGroups(options?: ToolRegistrationOptions): ToolGroupMap;
17
+ export declare function registerAllTools(server: McpServer, options?: ToolRegistrationOptions): void;
18
+ export {};