@startsimpli/api 0.1.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 (38) hide show
  1. package/README.md +329 -0
  2. package/package.json +42 -0
  3. package/src/__tests__/jwt-refresh.test.ts +195 -0
  4. package/src/__tests__/query-params.test.ts +144 -0
  5. package/src/__tests__/url-builder.test.ts +121 -0
  6. package/src/constants/endpoints.ts +39 -0
  7. package/src/index.ts +109 -0
  8. package/src/lib/api-client.ts +89 -0
  9. package/src/lib/contacts-api.ts +111 -0
  10. package/src/lib/cors.ts +122 -0
  11. package/src/lib/entities-api.ts +123 -0
  12. package/src/lib/env.ts +35 -0
  13. package/src/lib/error-handler.ts +138 -0
  14. package/src/lib/errors.ts +381 -0
  15. package/src/lib/fetch-wrapper.ts +188 -0
  16. package/src/lib/llm-sanitize.ts +145 -0
  17. package/src/lib/messages-api.ts +273 -0
  18. package/src/lib/messages-api.ts.backup +273 -0
  19. package/src/lib/organizations-api.ts +132 -0
  20. package/src/lib/rate-limit.ts +91 -0
  21. package/src/lib/sanitize.ts +39 -0
  22. package/src/lib/workflows-api.ts +159 -0
  23. package/src/middleware/index.ts +12 -0
  24. package/src/middleware/with-auth.ts +90 -0
  25. package/src/middleware/with-error-handling.ts +83 -0
  26. package/src/middleware/with-validation.ts +110 -0
  27. package/src/types/api.ts +38 -0
  28. package/src/types/contact.ts +49 -0
  29. package/src/types/entity.ts +153 -0
  30. package/src/types/error.ts +129 -0
  31. package/src/types/funnel.ts +133 -0
  32. package/src/types/index.ts +95 -0
  33. package/src/types/organization.ts +49 -0
  34. package/src/types/response.ts +44 -0
  35. package/src/types/workflow.ts +69 -0
  36. package/src/utils/index.ts +13 -0
  37. package/src/utils/query-params.ts +79 -0
  38. package/src/utils/url-builder.ts +78 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Type-safe fetch wrapper for Django REST API
3
+ */
4
+
5
+ import type { FetchOptions, HttpMethod } from '../types';
6
+ import { buildUrl, buildQueryString } from '../utils';
7
+ import { ApiException, parseErrorResponse, handleFetchError } from './error-handler';
8
+
9
+ export interface FetchWrapperConfig {
10
+ baseUrl: string;
11
+ getToken?: () => Promise<string | null> | string | null;
12
+ onUnauthorized?: () => void;
13
+ onTokenRefresh?: () => Promise<string | null>;
14
+ defaultHeaders?: HeadersInit;
15
+ }
16
+
17
+ export class FetchWrapper {
18
+ private config: FetchWrapperConfig;
19
+ private isRefreshing = false;
20
+ private refreshPromise: Promise<string | null> | null = null;
21
+
22
+ constructor(config: FetchWrapperConfig) {
23
+ this.config = config;
24
+ }
25
+
26
+ /**
27
+ * Build headers with auth token
28
+ */
29
+ private async buildHeaders(customHeaders?: HeadersInit): Promise<Headers> {
30
+ const headers = new Headers(this.config.defaultHeaders);
31
+
32
+ // Add custom headers
33
+ if (customHeaders) {
34
+ const customHeadersObj = new Headers(customHeaders);
35
+ customHeadersObj.forEach((value, key) => {
36
+ headers.set(key, value);
37
+ });
38
+ }
39
+
40
+ // Add auth token if available
41
+ if (this.config.getToken) {
42
+ const token = await this.config.getToken();
43
+ if (token) {
44
+ headers.set('Authorization', `Bearer ${token}`);
45
+ }
46
+ }
47
+
48
+ // Always set content-type for JSON
49
+ if (!headers.has('Content-Type')) {
50
+ headers.set('Content-Type', 'application/json');
51
+ }
52
+
53
+ return headers;
54
+ }
55
+
56
+ /**
57
+ * Execute fetch request
58
+ */
59
+ private async execute<T>(
60
+ method: HttpMethod,
61
+ endpoint: string,
62
+ options?: FetchOptions
63
+ ): Promise<T> {
64
+ try {
65
+ const { params, headers: customHeaders, ...fetchOptions } = options || {};
66
+
67
+ // Build URL
68
+ const url = buildUrl({
69
+ baseUrl: this.config.baseUrl,
70
+ endpoint,
71
+ params,
72
+ });
73
+
74
+ // Build headers
75
+ const headers = await this.buildHeaders(customHeaders);
76
+
77
+ // Execute request
78
+ let response = await fetch(url, {
79
+ method,
80
+ headers,
81
+ credentials: 'include',
82
+ ...fetchOptions,
83
+ });
84
+
85
+ // Handle 401 Unauthorized - attempt token refresh
86
+ if (response.status === 401 && this.config.onTokenRefresh) {
87
+ // Prevent multiple simultaneous refresh attempts
88
+ if (!this.isRefreshing) {
89
+ this.isRefreshing = true;
90
+ this.refreshPromise = this.config.onTokenRefresh().finally(() => {
91
+ this.isRefreshing = false;
92
+ this.refreshPromise = null;
93
+ });
94
+ }
95
+
96
+ const newToken = await this.refreshPromise;
97
+
98
+ if (newToken) {
99
+ // Retry original request with new token
100
+ const retryHeaders = new Headers(headers);
101
+ retryHeaders.set('Authorization', `Bearer ${newToken}`);
102
+
103
+ response = await fetch(url, {
104
+ method,
105
+ headers: retryHeaders,
106
+ credentials: 'include',
107
+ ...fetchOptions,
108
+ });
109
+ } else if (this.config.onUnauthorized) {
110
+ // Refresh failed - call unauthorized callback
111
+ this.config.onUnauthorized();
112
+ }
113
+ }
114
+
115
+ // Handle error responses
116
+ if (!response.ok) {
117
+ const error = await parseErrorResponse(response);
118
+ throw new ApiException(error.message || 'Request failed', error);
119
+ }
120
+
121
+ // Handle 204 No Content
122
+ if (response.status === 204) {
123
+ return undefined as T;
124
+ }
125
+
126
+ // Parse JSON response
127
+ return await response.json();
128
+ } catch (error) {
129
+ handleFetchError(error);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * GET request
135
+ */
136
+ async get<T>(endpoint: string, options?: FetchOptions): Promise<T> {
137
+ return this.execute<T>('GET', endpoint, options);
138
+ }
139
+
140
+ /**
141
+ * POST request
142
+ */
143
+ async post<T, D = unknown>(
144
+ endpoint: string,
145
+ data?: D,
146
+ options?: FetchOptions
147
+ ): Promise<T> {
148
+ return this.execute<T>('POST', endpoint, {
149
+ ...options,
150
+ body: data ? JSON.stringify(data) : undefined,
151
+ });
152
+ }
153
+
154
+ /**
155
+ * PUT request
156
+ */
157
+ async put<T, D = unknown>(
158
+ endpoint: string,
159
+ data?: D,
160
+ options?: FetchOptions
161
+ ): Promise<T> {
162
+ return this.execute<T>('PUT', endpoint, {
163
+ ...options,
164
+ body: data ? JSON.stringify(data) : undefined,
165
+ });
166
+ }
167
+
168
+ /**
169
+ * PATCH request
170
+ */
171
+ async patch<T, D = unknown>(
172
+ endpoint: string,
173
+ data?: D,
174
+ options?: FetchOptions
175
+ ): Promise<T> {
176
+ return this.execute<T>('PATCH', endpoint, {
177
+ ...options,
178
+ body: data ? JSON.stringify(data) : undefined,
179
+ });
180
+ }
181
+
182
+ /**
183
+ * DELETE request
184
+ */
185
+ async delete<T>(endpoint: string, options?: FetchOptions): Promise<T> {
186
+ return this.execute<T>('DELETE', endpoint, options);
187
+ }
188
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Input Sanitization for LLM Prompts
3
+ *
4
+ * Provides protection against prompt injection attacks by sanitizing
5
+ * user input before including it in LLM prompts.
6
+ */
7
+
8
+ /**
9
+ * Maximum allowed length for user prompts (characters)
10
+ */
11
+ export const MAX_PROMPT_LENGTH = 2000;
12
+
13
+ /**
14
+ * Maximum allowed length for chat messages (characters)
15
+ */
16
+ export const MAX_CHAT_MESSAGE_LENGTH = 1000;
17
+
18
+ /**
19
+ * Patterns that could be used for prompt injection
20
+ */
21
+ const INJECTION_PATTERNS = [
22
+ // System/role injection attempts
23
+ /\bSYSTEM\s*:/gi,
24
+ /\bASSISTANT\s*:/gi,
25
+ /\bUSER\s*:/gi,
26
+ /\bHUMAN\s*:/gi,
27
+ /\bAI\s*:/gi,
28
+ // Instruction override attempts
29
+ /\bignore\s+(previous|above|all)\s+instructions?\b/gi,
30
+ /\bdisregard\s+(previous|above|all)\s+instructions?\b/gi,
31
+ /\bforget\s+(previous|above|all)\s+instructions?\b/gi,
32
+ /\bnew\s+instructions?\s*:/gi,
33
+ /\boverride\s*:/gi,
34
+ // Role-playing attempts
35
+ /\byou\s+are\s+now\b/gi,
36
+ /\bact\s+as\s+(if|a|an|the)\b/gi,
37
+ /\bpretend\s+(to\s+be|you\s+are)\b/gi,
38
+ // JSON/schema manipulation
39
+ /\b(output|respond|return)\s+only\s*:/gi,
40
+ /\bformat\s*:\s*json\b/gi,
41
+ ];
42
+
43
+ /**
44
+ * Markdown/formatting sequences that could interfere with prompt structure
45
+ */
46
+ const FORMATTING_MARKERS = [
47
+ '```', // Code blocks
48
+ '---', // Horizontal rules (when at line start)
49
+ '***', // Alternative horizontal rules
50
+ '##', // Headers (when at line start)
51
+ '>>', // Potential quote injection
52
+ ];
53
+
54
+ /**
55
+ * Control characters to remove (except standard whitespace)
56
+ */
57
+ const CONTROL_CHAR_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
58
+
59
+ /**
60
+ * Sanitize user input for inclusion in LLM prompts
61
+ *
62
+ * @param input - Raw user input
63
+ * @param maxLength - Maximum allowed length (defaults to MAX_PROMPT_LENGTH)
64
+ * @returns Sanitized input safe for prompt inclusion
65
+ */
66
+ export function sanitizeUserInput(
67
+ input: string,
68
+ maxLength: number = MAX_PROMPT_LENGTH
69
+ ): string {
70
+ if (!input || typeof input !== 'string') {
71
+ return '';
72
+ }
73
+
74
+ let sanitized = input;
75
+
76
+ // 1. Remove control characters (keep newlines, tabs, spaces)
77
+ sanitized = sanitized.replace(CONTROL_CHAR_REGEX, '');
78
+
79
+ // 2. Normalize whitespace (collapse multiple spaces/newlines)
80
+ sanitized = sanitized
81
+ .replace(/\r\n/g, '\n') // Normalize line endings
82
+ .replace(/\r/g, '\n')
83
+ .replace(/\n{3,}/g, '\n\n') // Max 2 consecutive newlines
84
+ .replace(/[ \t]{3,}/g, ' ') // Max 2 consecutive spaces/tabs
85
+ .trim();
86
+
87
+ // 3. Strip or escape injection patterns
88
+ for (const pattern of INJECTION_PATTERNS) {
89
+ sanitized = sanitized.replace(pattern, (match) => {
90
+ // Replace with bracketed version to neutralize while preserving intent
91
+ return `[${match.replace(/:/g, '')}]`;
92
+ });
93
+ }
94
+
95
+ // 4. Escape formatting markers that could break prompt structure
96
+ for (const marker of FORMATTING_MARKERS) {
97
+ // Only escape when these appear at start of line or standalone
98
+ const escapeRegex = new RegExp(`(^|\\n)(${escapeRegexChars(marker)})`, 'g');
99
+ sanitized = sanitized.replace(escapeRegex, (_, prefix, match) => {
100
+ return `${prefix}[${match}]`;
101
+ });
102
+ }
103
+
104
+ // 5. Truncate to max length (at word boundary if possible)
105
+ if (sanitized.length > maxLength) {
106
+ sanitized = truncateAtWordBoundary(sanitized, maxLength);
107
+ }
108
+
109
+ return sanitized;
110
+ }
111
+
112
+ /**
113
+ * Sanitize chat message input (shorter limit, same rules)
114
+ */
115
+ export function sanitizeChatMessage(input: string): string {
116
+ return sanitizeUserInput(input, MAX_CHAT_MESSAGE_LENGTH);
117
+ }
118
+
119
+ /**
120
+ * Truncate string at a word boundary
121
+ */
122
+ function truncateAtWordBoundary(text: string, maxLength: number): string {
123
+ if (text.length <= maxLength) {
124
+ return text;
125
+ }
126
+
127
+ // Find the last space before maxLength
128
+ const truncated = text.slice(0, maxLength);
129
+ const lastSpace = truncated.lastIndexOf(' ');
130
+
131
+ // If we found a space in the last 20% of the string, truncate there
132
+ if (lastSpace > maxLength * 0.8) {
133
+ return truncated.slice(0, lastSpace).trim();
134
+ }
135
+
136
+ // Otherwise just hard truncate
137
+ return truncated.trim();
138
+ }
139
+
140
+ /**
141
+ * Escape special regex characters in a string
142
+ */
143
+ function escapeRegexChars(str: string): string {
144
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
145
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Messages API wrapper
3
+ *
4
+ * Provides type-safe access to Django Messages API
5
+ */
6
+
7
+ import type { ApiClient } from './api-client';
8
+
9
+ export type MessageStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
10
+ export type MessageContentType = 'text/plain' | 'text/markdown' | 'text/html';
11
+ export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'bounced' | 'opened' | 'clicked';
12
+
13
+ export interface Message {
14
+ id: string;
15
+ team: string;
16
+ channel: string;
17
+ channelDetails?: {
18
+ key: string;
19
+ name: string;
20
+ description: string;
21
+ icon: string;
22
+ };
23
+ subject: string;
24
+ body: string;
25
+ contentType: MessageContentType;
26
+ fromName: string | null;
27
+ fromEmail: string | null;
28
+ replyTo: string | null;
29
+ status: MessageStatus;
30
+ scheduledAt: string | null;
31
+ sentAt: string | null;
32
+ metadata: Record<string, any>;
33
+ totalRecipients: number;
34
+ recipientsSent: number;
35
+ recipientsDelivered: number;
36
+ recipientsOpened: number;
37
+ recipientsClicked: number;
38
+ recipientsBounced: number;
39
+ recipientsUnsubscribed: number;
40
+ openRate: number;
41
+ clickRate: number;
42
+ bounceRate: number;
43
+ errorMessage: string | null;
44
+ createdAt: string;
45
+ updatedAt: string;
46
+ }
47
+
48
+ export interface MessageRecipient {
49
+ id: string;
50
+ recipientType: string;
51
+ recipientId: string | null;
52
+ recipientEmail: string;
53
+ recipientName: string | null;
54
+ channelIdentifier: string | null;
55
+ channelProfiles: Record<string, any>;
56
+ status: RecipientStatus;
57
+ sentAt: string | null;
58
+ deliveredAt: string | null;
59
+ bouncedAt: string | null;
60
+ firstOpenedAt: string | null;
61
+ lastOpenedAt: string | null;
62
+ firstClickedAt: string | null;
63
+ lastClickedAt: string | null;
64
+ openCount: number;
65
+ clickCount: number;
66
+ isUnsubscribed: boolean;
67
+ unsubscribedAt: string | null;
68
+ errorMessage: string | null;
69
+ createdAt: string;
70
+ }
71
+
72
+ export interface MessagingChannel {
73
+ id: string;
74
+ key: string;
75
+ name: string;
76
+ description: string;
77
+ icon: string;
78
+ capabilities: {
79
+ maxContentLength: number;
80
+ supportsHtml: boolean;
81
+ supportsMarkdown: boolean;
82
+ supportsAttachments: boolean;
83
+ maxAttachments: number;
84
+ maxAttachmentSize: number;
85
+ };
86
+ requirements: {
87
+ requiresConnection: boolean;
88
+ requiresOptIn: boolean;
89
+ authRequirements: Record<string, any>;
90
+ };
91
+ rateLimit: {
92
+ messages: number;
93
+ seconds: number;
94
+ scope: string;
95
+ description: string;
96
+ };
97
+ metadata: Record<string, any>;
98
+ }
99
+
100
+ export interface MessageFilters {
101
+ status?: MessageStatus;
102
+ contentType?: MessageContentType;
103
+ scheduledAfter?: string;
104
+ scheduledBefore?: string;
105
+ sentAfter?: string;
106
+ sentBefore?: string;
107
+ search?: string;
108
+ page?: number;
109
+ pageSize?: number;
110
+ ordering?: string;
111
+ }
112
+
113
+ export interface CreateMessageInput {
114
+ channel: string;
115
+ subject: string;
116
+ body: string;
117
+ contentType?: MessageContentType;
118
+ fromName?: string;
119
+ fromEmail?: string;
120
+ replyTo?: string;
121
+ scheduledAt?: string;
122
+ metadata?: Record<string, any>;
123
+ recipients?: Array<{
124
+ recipientType: string;
125
+ recipientEmail: string;
126
+ recipientName?: string;
127
+ channelIdentifier?: string;
128
+ }>;
129
+ }
130
+
131
+ export interface ScheduleMessageInput {
132
+ scheduledAt: string;
133
+ }
134
+
135
+ export interface SendTestInput {
136
+ testEmail?: string;
137
+ }
138
+
139
+ export class MessagesApi {
140
+ constructor(private client: ApiClient) {}
141
+
142
+ /**
143
+ * List messages with optional filters
144
+ */
145
+ async list(filters?: MessageFilters) {
146
+ const params = new URLSearchParams();
147
+
148
+ if (filters?.status) params.append('status', filters.status);
149
+ if (filters?.contentType) params.append('contentType', filters.contentType);
150
+ if (filters?.scheduledAfter) params.append('scheduledAfter', filters.scheduledAfter);
151
+ if (filters?.scheduledBefore) params.append('scheduledBefore', filters.scheduledBefore);
152
+ if (filters?.sentAfter) params.append('sentAfter', filters.sentAfter);
153
+ if (filters?.sentBefore) params.append('sentBefore', filters.sentBefore);
154
+ if (filters?.search) params.append('search', filters.search);
155
+ if (filters?.page) params.append('page', String(filters.page));
156
+ if (filters?.pageSize) params.append('pageSize', String(filters.pageSize));
157
+ if (filters?.ordering) params.append('ordering', filters.ordering);
158
+
159
+ return this.client.get<{ results: Message[]; count: number; next: string | null; previous: string | null }>(
160
+ `/api/v1/messages/?${params.toString()}`
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Get message by ID
166
+ */
167
+ async get(id: string) {
168
+ return this.client.get<Message>(`/api/v1/messages/${id}/`);
169
+ }
170
+
171
+ /**
172
+ * Create a new message
173
+ */
174
+ async create(data: CreateMessageInput) {
175
+ return this.client.post<Message>('/api/v1/messages/', data);
176
+ }
177
+
178
+ /**
179
+ * Update message (draft only)
180
+ */
181
+ async update(id: string, data: Partial<CreateMessageInput>) {
182
+ return this.client.patch<Message>(`/api/v1/messages/${id}/`, data);
183
+ }
184
+
185
+ /**
186
+ * Delete message (draft only)
187
+ */
188
+ async delete(id: string) {
189
+ return this.client.delete(`/api/v1/messages/${id}/`);
190
+ }
191
+
192
+ /**
193
+ * Schedule message for future sending
194
+ */
195
+ async schedule(id: string, input: ScheduleMessageInput) {
196
+ return this.client.post<{ id: string; status: MessageStatus; scheduledAt: string; message: string }>(
197
+ `/api/v1/messages/${id}/schedule/`,
198
+ input
199
+ );
200
+ }
201
+
202
+ /**
203
+ * Send message immediately
204
+ */
205
+ async sendNow(id: string) {
206
+ return this.client.post<{ id: string; status: MessageStatus; message: string }>(
207
+ `/api/v1/messages/${id}/send_now/`,
208
+ {}
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Send test message
214
+ */
215
+ async sendTest(id: string, input?: SendTestInput) {
216
+ return this.client.post<{ id: string; testEmail: string; message: string }>(
217
+ `/api/v1/messages/${id}/send_test/`,
218
+ input || {}
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Preview message rendering
224
+ */
225
+ async preview(id: string) {
226
+ return this.client.get<{
227
+ subject: string;
228
+ body: string;
229
+ previewHtml: string;
230
+ fromName: string;
231
+ fromEmail: string;
232
+ }>(`/api/v1/messages/${id}/preview/`);
233
+ }
234
+
235
+ /**
236
+ * List recipients for a message
237
+ */
238
+ async getRecipients(id: string, page?: number, pageSize?: number) {
239
+ const params = new URLSearchParams();
240
+ if (page) params.append('page', String(page));
241
+ if (pageSize) params.append('pageSize', String(pageSize));
242
+
243
+ return this.client.get<{ results: MessageRecipient[]; count: number }>(
244
+ `/api/v1/messages/${id}/recipients/?${params.toString()}`
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Add recipients to a message
250
+ */
251
+ async addRecipients(
252
+ id: string,
253
+ recipients: Array<{
254
+ recipientType: string;
255
+ recipientEmail: string;
256
+ recipientName?: string;
257
+ channelIdentifier?: string;
258
+ }>
259
+ ) {
260
+ return this.client.post<{
261
+ message: string;
262
+ totalRecipients: number;
263
+ recipients: MessageRecipient[];
264
+ }>(`/api/v1/messages/${id}/add_recipients/`, { recipients });
265
+ }
266
+
267
+ /**
268
+ * Get available messaging channels
269
+ */
270
+ async getChannels() {
271
+ return this.client.get<{ channels: MessagingChannel[]; count: number }>('/api/v1/messages/channels/');
272
+ }
273
+ }